Gutenberg block editor security
When to use this skill
Use this skill whenever code interacts with the block editor beyond simple static blocks:
- Building dynamic blocks with a PHP
render_callback. - Using
ServerSideRenderto fetch server-rendered markup in the editor. - Exposing or saving block data via
register_rest_field(). - Handling
RichTextcontent or allowing markup in block attributes. - Enqueuing block-editor assets that call custom REST endpoints.
The block editor trusts server-rendered HTML and REST field values. If the server returns unescaped data or allows unauthorized writes, the vulnerability lands inside the editor UI and the saved post content.
Related: see the shortcode-block-security skill for attribute sanitization basics
and the rest-api-security skill for REST endpoints.
Core principles (and why they matter)
- Block attributes are user input. Declaring a type in
block.jsonvalidates shape but does not sanitize HTML or JavaScript. Sanitize inrender_callback. - Server render output must be escaped. The editor and front-end both render the callback's return value; escape for the context (HTML, attribute, URL, rich text).
- Editor REST fields need real permission callbacks.
register_rest_field()update_callbackmust checkcurrent_user_can( 'edit_post', $post_id )or a matching capability. - RichText allows markup — constrain it. Use
wp_kses_post()or a custom allowlist instead of raw storage. ServerSideRenderrequests are REST requests. They are public unless the block's REST route or render callback enforces capability checks.- Use
apiFetchwith theX-WP-Nonceheader. Modern editor JS usesapiFetch, which adds the nonce automatically for same-site REST requests.
Step-by-step implementation
- Define block attributes in
block.jsonwith types and defaults. - In the PHP
render_callback, sanitize each attribute before use. - Build markup with escaped values (
esc_html,esc_attr,esc_url,wp_kses_post). - If the block needs server-side rendering in the editor, ensure the underlying data source enforces permissions.
- For
register_rest_field()used by the block:- Add
schemawitharg_optionssanitize/validate callbacks. - In
update_callback, check the acting user's capability on the object.
- Add
- For
RichText, sanitize on save withwp_kses_post()and escape on render.
Supporting references
| Reference | Load when |
|---|---|
| Gutenberg block editor security checklist | Before final verification of the gutenberg block editor security controls. |
| Secure block editor integration | Implementing a dynamic block render callback and capability-gated REST field updates. |
Common AI mistakes / anti-patterns
Mistake 1 — Render callback echoing attributes unescaped
php// ❌ Insecure: stored XSS through a block attribute. function my_plugin_render_alert( $attributes ) { return '<div class="alert">' . $attributes['message'] . '</div>'; }
php// ✅ Secure: sanitize and escape the attribute. function my_plugin_render_alert( $attributes ) { $message = isset( $attributes['message'] ) ? sanitize_text_field( $attributes['message'] ) : ''; return '<div class="alert">' . esc_html( $message ) . '</div>'; }
Mistake 2 — register_rest_field update_callback with no capability check
php// ❌ Insecure: any authenticated user can update the field. register_rest_field( 'post', 'my_plugin_meta', array( 'get_callback' => function ( $object ) { return get_post_meta( $object['id'], '_my_plugin_meta', true ); }, 'update_callback' => function ( $value, $object ) { update_post_meta( $object->ID, '_my_plugin_meta', $value ); }, ) );
php// ✅ Secure: check capability before updating; sanitize value. register_rest_field( 'post', 'my_plugin_meta', array( 'get_callback' => function ( $object ) { return get_post_meta( $object['id'], '_my_plugin_meta', true ); }, 'update_callback' => function ( $value, $object ) { if ( ! current_user_can( 'edit_post', $object->ID ) ) { return new WP_Error( 'forbidden', __( 'You cannot edit this post.', 'my-plugin' ), array( 'status' => 403 ) ); } update_post_meta( $object->ID, '_my_plugin_meta', sanitize_text_field( $value ) ); return true; }, 'schema' => array( 'type' => 'string', 'arg_options' => array( 'sanitize_callback' => 'sanitize_text_field', ), ), ) );
Mistake 3 — Storing raw RichText without wp_kses_post
php// ❌ Insecure: RichText can contain script tags and event handlers. update_post_meta( $post_id, '_my_plugin_note', $attributes['note'] );
php// ✅ Secure: constrain rich markup. $note = isset( $attributes['note'] ) ? wp_kses_post( $attributes['note'] ) : ''; update_post_meta( $post_id, '_my_plugin_note', $note );
Mistake 4 — ServerSideRender of privileged data without checks
php// ❌ Insecure: block preview reveals data the editor user may not be allowed to see. function my_plugin_render_private( $attributes ) { return '<pre>' . get_option( 'my_plugin_secret_report' ) . '</pre>'; }
php// ✅ Secure: enforce the same capability in render_callback. function my_plugin_render_private( $attributes ) { if ( ! current_user_can( 'manage_options' ) ) { return '<p>' . esc_html__( 'You do not have permission to view this.', 'my-plugin' ) . '</p>'; } $report = get_option( 'my_plugin_secret_report', '' ); return '<pre>' . esc_html( $report ) . '</pre>'; }
Mistake 5 — Manual fetch without nonce
js// ❌ Insecure: custom fetch to a REST endpoint without the nonce. fetch( '/wp-json/my-plugin/v1/data' ) .then( r => r.json() );
js// ✅ Secure: use apiFetch, which attaches X-WP-Nonce automatically. import apiFetch from '@wordpress/api-fetch'; apiFetch( { path: 'my-plugin/v1/data' } ) .then( data => { /* ... */ } );
Mistake 6 — Trusting postMessage and raw HTML in editor code
js// ❌ Insecure: any window (an embedded preview, an ad iframe, an opener) can // post a message, and its HTML is rendered unsanitized in the editor. window.addEventListener( 'message', ( event ) => { setAttributes( { html: event.data.html } ); } ); // ... <RawHTML>{ attributes.html }</RawHTML>
js// ✅ Secure: check origin and shape, and never render untrusted markup raw. window.addEventListener( 'message', ( event ) => { if ( event.origin !== window.location.origin || typeof event.data?.text !== 'string' ) { return; } setAttributes( { text: event.data.text } ); } ); // Render as text (React escapes it), and sanitize server-side on render.
Treat RawHTML, dangerouslySetInnerHTML, innerHTML, and jQuery .html() as sinks
in admin and editor JavaScript. Deep-merging settings from URL parameters, storage, or
messages into objects (Object.assign, lodash.merge) can pollute prototypes; reject
__proto__, constructor, and prototype keys.
Correct code examples
A secure dynamic block with render_callback and a REST field with permission checks
is in references/secure-block-editor.php.
Checklist
- Block attributes are declared in
block.jsonwith types and defaults. -
render_callbacksanitizes every attribute before use. - Server-rendered output is escaped for its context.
-
register_rest_field()used by the block has schema + sanitize/validate callbacks. -
register_rest_field()update_callbackchecks the appropriate capability. -
RichTextand rich markup are constrained withwp_kses_post()or a custom allowlist. -
ServerSideRenderblocks enforce the same permissions as the front-end render. - Editor JS uses
apiFetch(or sendsX-WP-Nonce) for same-site REST calls. - No secrets are returned to the block editor for low-privilege users.
-
messagelisteners verifyevent.originagainst an allowlist and validate the payload shape. -
RawHTML/dangerouslySetInnerHTML/innerHTMLnever receive user- or message-controlled markup. - Settings deep-merged from URLs, storage, or messages reject
__proto__,constructor, andprototypekeys.


