How to create a secure shortcode with attributes in PHP in WordPress

Contents

Introduction

This article explains, in exhaustive detail, how to build secure, robust WordPress shortcodes that accept attributes. It covers attribute parsing, validation, sanitization, escaping for output, handling HTML in attributes and content, avoiding common security pitfalls (XSS, unsafe database usage, leaking unescaped data to JavaScript), performance considerations (caching), namespacing and registration, and examples for procedural and object-oriented implementations. All code examples are included in proper code blocks that you can copy into a plugin or theme file.

Shortcode fundamentals — function signature and registration

A WordPress shortcode callback receives three parameters: atts (attributes), content (enclosed content), and tag (the shortcode name). Typical signature:


For modern code organization, wrap shortcodes into a class and register via a static method or an instance method hooked to init or directly to add_shortcode.

Attribute parsing and defaults

Use shortcode_atts() to fill defaults and allow all expected attributes to be present. It merges user-provided attributes with your defaults. Always declare explicit defaults and types.

 ,       // string
    link       => ,       // URL
    count      => 5,        // integer
    show_title => true,   // boolean-ish string
    class      => ,       // CSS class
)
atts = shortcode_atts( defaults, atts, tag )
?>

Note: shortcode_atts does NOT sanitize values. It only supplies defaults and merges keys. Always validate and sanitize after merging.

Sanitization vs Escaping — what to use and when

Understanding the difference is essential:

  • Sanitization cleans and normalizes incoming data before it is stored or used internally (e.g., sanitize_text_field, absint, esc_url_raw for saving URLs, wp_kses for permitting limited HTML). Do this immediately after reading attributes.
  • Escaping prepares output for a specific context (HTML body, attribute, JS context). Use esc_html, esc_attr, esc_url, wp_json_encode, esc_js, etc., at the point of output.

Common WordPress sanitization functions and use-cases

Function Use case
sanitize_text_field() Plain text strips tags and extra whitespace.
esc_url_raw() Save or validate URLs before storing or using in a query.
esc_url() Escape URL for HTML output.
absint() / intval() Convert to integer use absint for non-negative values.
filter_var(value, FILTER_VALIDATE_BOOLEAN) Normalize boolean-ish attributes.
wp_kses() Allow a whitelist of HTML tags/attributes for safe HTML content.
sanitize_html_class() Make safe CSS class names.
wp_strip_all_tags() Remove all tags (useful when you need text only).

Attribute validation patterns

For typical attribute types, use these patterns after shortcode_atts:

  • Strings (plain text): title = sanitize_text_field( atts[title] )
  • HTML (limited): content = wp_kses( content, allowed_html ) where allowed_html is defined via wp_kses_allowed_html() or an array.
  • URLs: url = esc_url_raw( atts[link] ) then at output use esc_url( url ).
  • Integers: count = absint( atts[count] )
  • Booleans: show = filter_var( atts[show_title], FILTER_VALIDATE_BOOLEAN )
  • Classes: class = sanitize_html_class( atts[class] )

Escaping for output — context matters

Always escape immediately before output. Key escape functions by context:

  • HTML body: esc_html()
  • HTML attributes: esc_attr()
  • URLs: esc_url()
  • JS variables printed into script: wp_json_encode() or esc_js()
  • Inside a data- attribute: esc_attr( wp_json_encode( data ) )

Common security pitfalls and how to avoid them

  1. Trusting user-supplied HTML or attributes:

    Solution: Sanitize with wp_kses() using a tightly controlled allowed tag list. Prefer stripping HTML unless you explicitly allow it.

  2. Unescaped output leading to XSS:

    Solution: Escape for the correct context (esc_attr, esc_html, esc_url, etc.) at render time.

  3. Direct SQL queries with attributes:

    Solution: Use wpdb->prepare() for SQL queries pass integers via absint or prepare placeholders (%d, %s) instead of inserting raw attribute values.

  4. Injecting data into JavaScript unsafely:

    Solution: Use wp_localize_script or wp_json_encode and esc_attr when embedding into data attributes never echo unescaped JSON directly into a script tag.

  5. Allowing unprivileged users to execute sensitive code via shortcode:

    Solution: Gate functionality using current_user_can() checks when appropriate. If shortcodes will be used in the content editor, keep in mind that users who can edit posts can embed them. If you need admin-only behavior, check capabilities before outputting or performing actions.

  6. CSRF on forms produced inside shortcodes:

    Solution: If your shortcode outputs a form or triggers actions, include and verify nonces with wp_nonce_field() and check_admin_referer()/check_ajax_referer() on submission handlers.

Allowing limited HTML in content or attributes

When you want to permit some HTML in the content or in an attribute (for example, a small block of markup for a description), define an allowed list and use wp_kses():

 array(
        href => true,
        title => true,
        rel => true,
    ),
    strong => array(),
    em => array(),
    br => array(),
    p => array(),
)

clean_content = wp_kses( content, allowed_html )
?>

Do not use wp_kses_post() unless you intend to allow the same HTML as in post content even then, be conscious of what editors can insert.

Example 1 — Secure simple shortcode (procedural)

A shortcode that renders a feature box with title (text), link (URL), count (int), and optionally the enclosed content (limited HTML). Secure and namespaced.

 ,
        link       => ,
        count      => 3,
        show_title => true,
        class      => ,
    )
    atts = shortcode_atts( defaults, atts, tag )

    // Sanitize attributes
    title_raw = atts[title]
    title = sanitize_text_field( title_raw )

    // URL: store raw safe version, escape when outputting
    link_raw = atts[link]
    link = esc_url_raw( link_raw )

    // Integer
    count = absint( atts[count] )

    // Boolean normalize
    show_title = filter_var( atts[show_title], FILTER_VALIDATE_BOOLEAN )

    // CSS class safe
    class = sanitize_html_class( atts[class] )

    // Content: allow limited HTML
    allowed = wp_kses_allowed_html( post ) // or define your own
    clean_content = content ? wp_kses( content, allowed ) : 

    // Build output: escape at point of output
    parts = array()
    parts[] = 
if ( show_title title !== ) { parts[] =

. esc_html( title ) .

} if ( clean_content ) { parts[] =
. clean_content .
} // Example: count used in an attribute or text parts[] =
. esc_html( count ) .
// Link button (only if valid URL) if ( link ) { parts[] = . esc_html__( Learn more, my-text-domain ) . } parts[] =
return implode( n, parts ) } ?>

Example 2 — Class-based shortcode with caching and AJAX-safe nonce

A more advanced example that demonstrates a class, transient caching for rendered output, and how to attach a nonce to a button for an AJAX action initiated from the shortcode. This outlines how to securely expose AJAX endpoints and nonces without leaking sensitive information.

 0,
            class => ,
        )
        atts = shortcode_atts( defaults, atts, tag )

        id = absint( atts[id] )
        if ( ! id ) {
            return  // invalid or missing id
        }

        class = sanitize_html_class( atts[class] )

        // Use transient to cache rendered HTML per id   class
        cache_key = my_prefix_widget_ . id . _ . md5( class )
        html = get_transient( cache_key )
        if ( false !== html ) {
            return html
        }

        // Retrieve data safely via WP functions or prepare statements
        global wpdb
        table = wpdb->prefix . my_table
        row = wpdb->get_row( wpdb->prepare( SELECT title, content FROM {table} WHERE id = %d, id ) )
        if ( ! row ) {
            return 
        }

        title = sanitize_text_field( row->title )
        content_safe = wp_kses( row->content, wp_kses_allowed_html( post ) )

        // Create an AJAX nonce specific to this widget instance
        nonce = wp_create_nonce( my_prefix_widget_ . id )

        // Use data attributes and JSON for safe JS transfer
        data = array( id => id, nonce => nonce )
        data_attr = esc_attr( wp_json_encode( data ) )

        parts = array()
        parts[] = 
parts[] =

. esc_html( title ) .

parts[] =
. content_safe .
parts[] = parts[] =
html = implode( n, parts ) // Cache for 10 minutes set_transient( cache_key, html, 10 MINUTE_IN_SECONDS ) // Enqueue scripts/styles separately in proper hooks. This demonstrates safe JS data passing. // wp_enqueue_script( my-prefix-widget ) etc. return html } public function ajax_handler() { // Check required params id = isset( _POST[id] ) ? absint( _POST[id] ) : 0 nonce = isset( _POST[nonce] ) ? sanitize_text_field( wp_unslash( _POST[nonce] ) ) : if ( ! id ! nonce ) { wp_send_json_error( array( message => Invalid request ), 400 ) } // Verify nonce (match creation above) if ( ! wp_verify_nonce( nonce, my_prefix_widget_ . id ) ) { wp_send_json_error( array( message => Invalid nonce ), 403 ) } // Optionally check capability for sensitive actions // if ( ! current_user_can( edit_post, post_id ) ) { ... } // Safe DB action example: use prepare() global wpdb table = wpdb->prefix . my_table result = wpdb->get_row( wpdb->prepare( SELECT id, status FROM {table} WHERE id = %d, id ) ) if ( ! result ) { wp_send_json_error( array( message => Not found ), 404 ) } // Perform some safe update or return data wp_send_json_success( array( id => result->id, status => result->status ) ) } } new My_Prefix_Shortcodes() ?>

Handling boolean and flag attributes

WordPress attribute values are strings. Common patterns to support boolean-like values are:

  • Accept true/false, 1/0, on/off, or presence-only flags.
  • Normalize via filter_var(): flag = filter_var( atts[flag], FILTER_VALIDATE_BOOLEAN )

If you want a presence-only attribute (e.g., [shortcode compact]), detect via array key existence:

 true or 
compact = isset( atts[compact] )  atts[compact] !== false
?>

Internationalization (i18n) and text domain

When your shortcode outputs any user-visible strings, wrap them for translation. Use esc_html__(), esc_attr__(), __(), _e() as appropriate. Example:

 . label . 
?>

Registering assets safely (scripts and styles) used by shortcodes

Enqueue scripts and styles in the proper hooks (wp_enqueue_scripts for front-end). Do not echo script tags inside shortcodes. Pass server-side data via wp_localize_script() or wp_add_inline_script() using properly escaped JSON values (wp_json_encode).

 admin_url( admin-ajax.php ) )
wp_localize_script( my-prefix-widget, MyPrefixWidget, data )
?>

Testing and debugging tips

  • Log raw attribute arrays to debug (only in development) using error_log( wp_json_encode( atts ) ) — never leave debug logging on production in a way that reveals secrets.
  • Unit test shortcode callback logic separately from WordPress rendering when possible.
  • Use Query Monitor or other debugging tools to inspect SQL queries and confirm the use of prepared statements.
  • Validate output using security plugins or automated scanners to detect XSS or unsafe output.

Best practices checklist (summary)

  • Always supply defaults with shortcode_atts().
  • Sanitize attributes immediately after merging defaults.
  • Escape values when outputting, using the correct escaping function for the context.
  • Use wp_kses() for any allowed HTML and keep the allowed list as tight as practical.
  • Protect any forms or AJAX endpoints with nonces and capability checks where appropriate.
  • Use wpdb->prepare() for direct SQL use WP_Query or get_posts when possible.
  • Namespace shortcodes and functions to avoid collisions (prefix or class methods).
  • Enqueue assets properly, and pass data to JS using wp_localize_script() or wp_json_encode esc_attr for inline attributes.
  • Cache rendered output when safe (transients) to improve performance.

Troubleshooting common issues

  1. Shortcode output appears unescaped or raw HTML shows:

    Check that you used escape functions (esc_html/esc_attr) at render time. Also confirm that the theme is not stripping tags or that do_shortcode() is being called in the right place.

  2. Attributes are empty or missing:

    Confirm attribute names and that defaults are declared. Remember that boolean presence-only attributes may appear with an empty string. Inspect the raw atts via error_log during development.

  3. AJAX nonce fails:

    Ensure you create nonce with the same action and verify it with wp_verify_nonce() or check_ajax_referer(). If using a per-instance nonce, include the identifier in the action.

  4. Shortcode runs inside admin unexpectedly:

    Shortcodes may be executed in admin contexts (preview). Use is_admin() carefully if you need to alter behavior for the admin area.

Final robust example — everything put together

A compact but complete plugin-style shortcode demonstrating best practices: defaults, sanitization, escaping, limited HTML content, caching, and safe URL handling.

 ,
        url   => ,
        items => 3,
        mode  => compact, // allowed values: compact, expanded
        class => ,
    )

    atts = shortcode_atts( defaults, atts, secure_feature )

    // Validate mode as a known value
    mode = in_array( atts[mode], array( compact, expanded ), true ) ? atts[mode] : compact

    // Sanitize primitives
    title = sanitize_text_field( atts[title] )
    url_raw = atts[url]
    url = esc_url_raw( url_raw ) // safe internal form
    items = absint( atts[items] )
    class = sanitize_html_class( atts[class] )

    // Clean content with a minimal allowed tags list
    allowed = array(
        strong => array(),
        em     => array(),
        a      => array( href => true, rel => true ),
        p      => array(),
        ul     => array(),
        li     => array(),
    )
    clean_content = content ? wp_kses( content, allowed ) : 

    // Build HTML safely
    html  = 
if ( title !== ) { html .=

. esc_html( title ) .

} if ( clean_content ) { html .=
. clean_content .
} html .=
    for ( i = 1 i <= max(1, items) i ) { html .=
  • . esc_html( sprintf( __( Item %d, secure-feature ), i ) ) .
  • } html .=
if ( url ) { // Use rel for security on external links html .= . esc_html__( Read more, secure-feature ) . } html .=
return html } ?>

Closing summary

Secure shortcodes are built by carefully combining defaults, strict sanitization of input attributes, and correct escaping at render time. Use well-known WordPress helper functions (sanitize_text_field, esc_url_raw, absint, wp_kses, esc_html, esc_attr, esc_url, wp_json_encode) and ensure any dynamic database or AJAX activity is protected by prepared statements, nonces, and capability checks. Namespace everything, avoid echoing scripts inline from shortcode callbacks (enqueue assets instead), and cache rendered output when possible.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *