Shortcode & dynamic block security
When to use this skill
Use this skill whenever code renders content from shortcodes or dynamic blocks:
- Registering a shortcode with
add_shortcode(). - Reading or outputting shortcode attributes (
$atts) or enclosed content ($content). - Registering a dynamic block with a
render_callback. - Reading block attributes in a server-side render.
- Building HTML, URLs, or classes from shortcode/block input.
Shortcodes and dynamic blocks are stored XSS sinks: a contributor enters [my_card title="<script>..."]
and the render callback echoes it unescaped. Every attribute must be sanitized and every output escaped.
Related: see the output-escaping skill for context-correct escaping and the
gutenberg-block-editor-security skill for broader block-editor surfaces.
Core principles (and why they matter)
shortcode_atts()sets defaults; it does NOT sanitize. The returned array still holds raw user input. Sanitize each value before use.- Block attributes are user input too. Declaring
type: 'string'inblock.jsondoes not escape HTML or JavaScript for you; sanitize on render. - Escape at render for the exact context. HTML body →
esc_html(). HTML attribute →esc_attr(). URL →esc_url(). Rich HTML →wp_kses_post()with an allowlist. - Do not store unescaped attribute values. If you persist them, sanitize on save and escape on read.
- Never pass shortcode/block input to
do_shortcode()oreval()uncontrolled. Both can execute arbitrary shortcodes or code. - Return, don't echo. Shortcode and block render callbacks must return strings; echoing produces unexpected output placement.
Step-by-step implementation
- In the shortcode callback, call
shortcode_atts()with a complete default map. - Sanitize each attribute to its expected type (
absint,sanitize_text_field,esc_url_raw,sanitize_key). - In a block
render_callback, read attributes from the$attributesarray and sanitize them the same way. - Build the markup by concatenating escaped values.
- Return the complete markup string.
- For rich content, use
wp_kses_post()or a tightly scopedwp_kses()allowlist.
Supporting references
| Reference | Load when |
|---|---|
| Shortcode & dynamic block security checklist | Before final verification of the shortcode & dynamic block security controls. |
| Secure shortcode and dynamic block | Implementing shortcode and dynamic-block render callbacks with validated attributes and escaped markup. |
Common AI mistakes / anti-patterns
Mistake 1 — Echoing $atts directly
// ❌ Insecure: stored XSS through the title attribute.
function my_plugin_card_shortcode( $atts ) {
return '<div class="card"><h3>' . $atts['title'] . '</h3></div>';
}
// ✅ Secure: default, then sanitize, then escape.
function my_plugin_card_shortcode( $atts ) {
$atts = shortcode_atts(
array(
'title' => '',
'link' => '',
),
$atts,
'my_plugin_card'
);
$title = sanitize_text_field( $atts['title'] );
$link = esc_url_raw( $atts['link'] );
$output = '<div class="card">';
if ( $link ) {
$output .= '<h3><a href="' . esc_url( $link ) . '">' . esc_html( $title ) . '</a></h3>';
} else {
$output .= '<h3>' . esc_html( $title ) . '</h3>';
}
$output .= '</div>';
return $output;
}
Mistake 2 — Trusting shortcode_atts() to sanitize
// ❌ Insecure: shortcode_atts only supplies defaults and filters unknown keys.
$atts = shortcode_atts( array( 'class' => '' ), $atts );
echo '<div class="' . $atts['class'] . '">...</div>';
// ✅ Secure: sanitize the value after normalizing it.
$atts = shortcode_atts( array( 'class' => '' ), $atts, 'my_plugin_box' );
$class = sanitize_html_class( $atts['class'] );
echo '<div class="' . esc_attr( $class ) . '">...</div>';
Mistake 3 — Dynamic block render callback echoing attributes
// ❌ Insecure: block attributes echoed raw.
function my_plugin_render_banner( $attributes ) {
?>
<div class="banner" style="background: <?php echo $attributes['bgColor']; ?>">
<?php echo $attributes['heading']; ?>
</div>
<?php
}
// ✅ Secure: sanitize attributes and escape for the output context.
function my_plugin_render_banner( $attributes ) {
$bg_color = isset( $attributes['bgColor'] ) ? sanitize_hex_color( $attributes['bgColor'] ) : '#ffffff';
$heading = isset( $attributes['heading'] ) ? sanitize_text_field( $attributes['heading'] ) : '';
return sprintf(
'<div class="banner" style="background: %s"><h2>%s</h2></div>',
esc_attr( $bg_color ),
esc_html( $heading )
);
}
Mistake 4 — Allowing arbitrary HTML through attributes
// ❌ Insecure: an attacker can inject script/event handlers.
function my_plugin_render_note( $attributes ) {
return '<div>' . $attributes['content'] . '</div>';
}
// ✅ Secure: constrain rich markup with wp_kses_post.
function my_plugin_render_note( $attributes ) {
$content = isset( $attributes['content'] ) ? $attributes['content'] : '';
return '<div>' . wp_kses_post( $content ) . '</div>';
}
Mistake 5 — Running do_shortcode() on untrusted input
// ❌ Insecure: executes arbitrary shortcodes supplied by a visitor.
echo do_shortcode( $_POST['content'] );
// ✅ Secure: do not run do_shortcode on user input; if required, sanitize first.
$content = wp_kses_post( wp_unslash( $_POST['content'] ?? '' ) );
Correct code examples
A complete secure shortcode and dynamic-block render callback is in
references/secure-shortcode-block.php.
Checklist
-
shortcode_atts()provides defaults for every supported attribute. - Each shortcode attribute is sanitized to its expected type after normalization.
- Block attributes are sanitized inside
render_callback, not trusted fromblock.jsontypes. - All rendered output is escaped for its context (
esc_html,esc_attr,esc_url,wp_kses_post). - CSS classes use
sanitize_html_class()(oresc_attr()with an allowlist). - Colors use
sanitize_hex_color()where appropriate. - The callback returns a string; it does not echo directly.
-
do_shortcode()is never run on untrusted input.