Contents
Introduction
This tutorial explains, in exhaustive detail, how to add a meta box to the WordPress post edit screen, display meta fields inside it, protect the form with a nonce, and securely save the meta fields when the post is saved. It covers best practices, sanitization, capability checks, autosave and revision handling, examples for multiple field types, storing single or grouped meta, enqueuing scripts/styles for media uploaders, and common pitfalls.
Prerequisites and conventions
- WordPress development basics: understanding of themes/plugins, actions and filters, and basics of PHP.
- Use unique, namespaced meta keys and function names to avoid collisions. Use a short prefix (plugin or theme slug) like myprefix or myplugin.
- Hide meta keys from the custom fields UI by prefixing keys with an underscore (e.g., _myprefix_subtitle) if desired.
- Always escape output and sanitize input. Never trust user-submitted data.
Overview of the workflow
- Register the meta box using add_meta_box on the add_meta_boxes hook.
- Render the meta box HTML, including fields and a nonce field (wp_nonce_field).
- Enqueue needed scripts/styles for the admin screen if you have JS-powered controls (e.g., media uploader).
- Hook save_post (or better: save_post_{post_type}) to validate the nonce, check autosaves/revisions, verify permissions, sanitize the inputs, and update_post_meta.
- Escape fields on output (esc_attr, esc_textarea, esc_html, etc.) when redisplaying values in inputs.
Common security checks and why they matter
- Nonce verification — protects against CSRF. Use wp_nonce_field() when outputting, and wp_verify_nonce() on save.
- Autosave and revision checks — WordPress triggers save_post during autosaves and when saving revisions typically you want to avoid updating meta on these events.
- Capability checks — ensure the current user can edit the post (current_user_can(edit_post, post_id)).
- Sanitization — sanitize incoming values before saving using appropriate functions (sanitize_text_field, sanitize_textarea_field, absint, sanitize_email, esc_url_raw, wp_kses_post, etc.).
- Escaping — escape data when outputting into inputs (esc_attr, esc_textarea) to avoid XSS.
Recommended meta key and function naming pattern
Use a consistent prefix, e.g. myprefix. Examples:
- Meta key for subtitle: _myprefix_subtitle (leading underscore hides field).
- Nonce and action names: myprefix_save_meta and nonce name myprefix_nonce.
- Functions: myprefix_register_meta_box, myprefix_meta_box_callback, myprefix_save_meta_box.
Full example: a plugin-style implementation (single text field checkbox textarea)
The following example shows a complete plugin-style implementation that adds a meta box to posts, displays a subtitle (text), a featured checkbox, and a note (textarea). It includes nonce generation, verification, sanitization, escaping, and capability/ autosave checks.
ID, this->meta_subtitle, true ) featured = get_post_meta( post->ID, this->meta_featured, true ) note = get_post_meta( post->ID, this->meta_note, true ) // Output nonce field for verification wp_nonce_field( this->nonce_action, this->nonce_name ) // Always escape values when outputting in form fields ?>
class=widefat />
nonce_name ] ) ) { return post_id } nonce = _POST[ this->nonce_name ] if ( ! wp_verify_nonce( nonce, this->nonce_action ) ) { return post_id } // 2) Autosave, revision or AJAX checks if ( defined( DOING_AUTOSAVE ) DOING_AUTOSAVE ) { return post_id } if ( wp_is_post_autosave( post_id ) wp_is_post_revision( post_id ) ) { return post_id } // 3) Capability check: can the current user edit this post? if ( ! current_user_can( edit_post, post_id ) ) { return post_id } // 4) Sanitize and save each field // Subtitle: single-line text if ( isset( _POST[myprefix_subtitle] ) ) { subtitle_sanitized = sanitize_text_field( wp_unslash( _POST[myprefix_subtitle] ) ) update_post_meta( post_id, this->meta_subtitle, subtitle_sanitized ) } else { // If you want to delete when unchecked/empty, you can delete_post_meta() // delete_post_meta( post_id, this->meta_subtitle ) } // Featured: checkbox, should be 1 or 0 featured_value = isset( _POST[myprefix_featured] ) ? 1 : 0 update_post_meta( post_id, this->meta_featured, featured_value ) // Note: multi-line text, allow some HTML via wp_kses_post if needed if ( isset( _POST[myprefix_note] ) ) { note_sanitized = sanitize_textarea_field( wp_unslash( _POST[myprefix_note] ) ) update_post_meta( post_id, this->meta_note, note_sanitized ) } } public function enqueue_admin_assets( hook ) { // Only enqueue on post edit screens to keep admin lean if ( post.php !== hook post-new.php !== hook ) { return } // Optionally enqueue styles/scripts here for custom metabox UI // wp_enqueue_style( myprefix-admin, plugin_dir_url(__FILE__) . css/admin.css ) // wp_enqueue_script( myprefix-admin, plugin_dir_url(__FILE__) . js/admin.js, array(jquery), 1.0, true ) } } new MyPrefix_Meta_Box()
Explanation of the previous example
- add_meta_box is called inside the add_meta_boxes hook. It adds a meta box to the post edit screen.
- The callback outputs fields and a nonce via wp_nonce_field(action, name).
- On save_post the code first checks for the nonce, verifies it, and returns early on failure.
- It checks autosave and revisions to avoid unwanted updates during those events.
- It checks current_user_can(edit_post, post_id) to ensure the user has permission.
- It sanitizes each field appropriately and uses update_post_meta to persist values.
- When outputting into inputs the code uses esc_attr and esc_textarea to escape saved values.
Using grouped meta (store multiple fields in one meta entry)
You can store multiple related fields in a single meta entry as an associative array. This reduces the number of meta rows but requires serializing/unserializing (WordPress handles that). Example:
// Inside your save handler: data = array( subtitle => isset(_POST[myprefix_subtitle]) ? sanitize_text_field(wp_unslash(_POST[myprefix_subtitle])) : , featured => isset(_POST[myprefix_featured]) ? 1 : 0, note => isset(_POST[myprefix_note]) ? sanitize_textarea_field(wp_unslash(_POST[myprefix_note])) : , ) update_post_meta( post_id, _myprefix_all_meta, data ) // To retrieve: all_meta = get_post_meta( post_id, _myprefix_all_meta, true ) subtitle = isset( all_meta[subtitle] ) ? all_meta[subtitle] :
Sanitization and validation quick reference
Input type | Sanitize function | Notes |
Single-line text | sanitize_text_field() | Strips tags, removes line breaks. |
Textarea (plain) | sanitize_textarea_field() | Removes harmful tags, keeps basic text for limited HTML use wp_kses_post() |
HTML content | wp_kses_post() | Allows post-safe HTML specify allowed tags via wp_kses() for custom rules. |
Integer IDs | absint() | Converts to positive integer (0 ). |
Float/number | floatval() / intval() | Cast to numeric types. |
URL | esc_url_raw() / esc_url() | esc_url_raw() for saving esc_url() for display. |
sanitize_email() | Validates and sanitizes email addresses. | |
File names | sanitize_file_name() | Useful when handling uploaded file names. |
Nonce specifics and best practices
- Use wp_nonce_field() when outputting forms. The function echoes a hidden input and optionally a referer field.
- On save, use wp_verify_nonce( nonce_value, action ) to check validity.
- Nonces are time-limited. The default lifetime is controlled by WP_NONCE_LIFE or filtered via nonce_life. Nonces are not meant as absolute authentication tokens but as one-time CSRF protections.
- Use distinct action strings per metabox or operation (e.g., myprefix_save_meta).
Handling autosave, revisions, and AJAX
When WordPress autosaves a post, or when it creates revisions, the save_post hook fires. Usually, you should ignore these events for meta saving unless you have a specific reason to process autosaves or revisions. The common checks are:
- defined(DOING_AUTOSAVE) and DOING_AUTOSAVE
- wp_is_post_autosave(post_id)
- wp_is_post_revision(post_id)
Saving checkbox values reliably
Checkboxes are only present in POST data when checked. To ensure stored value is explicit, set to 1 when present and 0 or delete when not:
featured_value = isset(_POST[myprefix_featured]) ? 1 : 0 update_post_meta( post_id, _myprefix_featured, featured_value )
Enqueuing scripts and using the WordPress media uploader in a metabox
If your metabox includes an image uploader or JS enhancements, enqueue scripts/styles only on post screens and localize strings or pass data as needed. Example for enqueuing the media scripts and adding a simple JS file to open the WP media modal:
add_action( admin_enqueue_scripts, myprefix_enqueue_media ) function myprefix_enqueue_media( hook ) { if ( post.php !== hook post-new.php !== hook ) { return } // Enqueue WordPress media scripts wp_enqueue_media() // Enqueue your custom JS for handling the uploader wp_enqueue_script( myprefix-metabox, plugin_dir_url(__FILE__) . js/myprefix-metabox.js, array(jquery), 1.0, true ) // Optionally pass data to JS wp_localize_script( myprefix-metabox, myprefixMeta, array( title => __( Choose or Upload an Image, textdomain ), button => __( Use this image, textdomain ), ) ) }
// js/myprefix-metabox.js jQuery(document).ready(function(){ var frame (#myprefix_image_button).on(click, function(e){ e.preventDefault() // If the media frame already exists, reopen it. if ( frame ) { frame.open() return } // Create the media frame. frame = wp.media({ title: myprefixMeta.title, button: { text: myprefixMeta.button }, multiple: false }) frame.on( select, function() { var attachment = frame.state().get(selection).first().toJSON() (#myprefix_image_id).val(attachment.id) (#myprefix_image_preview).attr(src, attachment.sizes.thumbnail ? attachment.sizes.thumbnail.url : attachment.url) }) // Finally, open the modal frame.open() }) })
Saving uploaded attachment ID from the media uploader (example)
// In your meta box HTML output // /> //style=max-width:150px /> // // In save handler: if ( isset( _POST[myprefix_image_id] ) ) { image_id = absint( _POST[myprefix_image_id] ) update_post_meta( post_id, _myprefix_image_id, image_id ) }
Displaying meta values on the front end
When using meta values on the front end, always escape appropriately:
- For text inside an attribute: esc_attr( get_post_meta( id, _myprefix_subtitle, true ) )
- For text inside HTML content: esc_html( value ) or wp_kses_post( value ) if some HTML is allowed.
- For image URLs: esc_url( url )
Debugging tips
- Use error_log or WP_DEBUG to check if your save handler runs.
- Confirm that your nonce name matches exactly between wp_nonce_field and the saved POST check.
- Ensure the field names in HTML match the keys used in _POST during saving.
- Check that your hooks are added at the correct points and not inside conditionals that prevent them from executing.
Advanced topics and variations
- Use save_post_{post_type} (e.g., save_post_page) to only hook saves for a specific post type (avoids extra checks inside callback).
- Use register_post_meta (introduced in WP 4.9 ) for meta that you want to expose via REST API with schema and single/multiple settings. register_post_meta has its own sanitize callback option.
- For very complex forms consider using the Settings API or a custom table if you need querying performance at scale.
- For repeatable fields, store as serialized arrays, or store repeated items as separate meta entries (update_post_meta vs add_post_meta) depending on query needs.
Example: save_post_{post_type} variation
To limit the save handler to a single post type (e.g., book), hook into save_post_book:
add_action( save_post_book, myprefix_save_book_meta, 10, 2 ) function myprefix_save_book_meta( post_id, post ) { // Same nonce, autosave, capability checks as earlier example... }
Why use save_post_{post_type}?
It ensures your callback only runs when that post type is saved, reducing conditional checks inside the function and avoiding accidental runs for other post types.
Common mistakes and how to avoid them
- Forgetting to include the nonce field in the meta box output — then wp_verify_nonce will always fail.
- Mismatching nonce action or name — both values must match between wp_nonce_field and wp_verify_nonce.
- Not using wp_unslash when reading _POST values in save hooks — WP core slashes input and you should remove slashes before sanitizing.
- Saving unchecked checkboxes incorrectly — use a clear explicit value for unchecked state.
- Updating meta during autosave or revision — check DOING_AUTOSAVE and revision functions to avoid this.
- Not escaping output when rendering the fields (esc_attr / esc_textarea) — leads to XSS vulnerabilities or broken form values.
Minimal checklist before shipping
- Unique prefix for meta keys and function names.
- Nonce added in the form and verified on save.
- Autosave, revision, and capability checks present.
- Values sanitized before saving and escaped before output.
- Scripts/styles enqueued only when needed.
- Consider registering meta with register_post_meta if you need REST support.
Conclusion
This tutorial covered everything necessary to add meta boxes, display input fields, protect them with nonces, and securely save meta fields in WordPress using PHP. Follow the patterns above: namespace keys and functions, verify nonces, check permissions, avoid autosaves/revisions, sanitize inputs, and escape outputs. For more advanced UI (media uploaders, repeatable fields), enqueue media and handle JS carefully, always validating server-side as well.
Useful links
- WordPress Developer: Custom Metaboxes
- wp_nonce_field()
- wp_verify_nonce()
- add_meta_box()
- update_post_meta()
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |