Cross-site scripting in WordPress plugins usually comes down to one boring truth: untrusted data reached HTML, JavaScript, or an attribute without the right escaping.

I’ve reviewed a lot of plugin code over the years, and the same patterns keep showing up:

  • $_GET echoed into admin pages
  • option values printed without escaping
  • post meta dropped into attributes
  • localized script data built unsafely
  • AJAX handlers returning HTML stitched together from user input

WordPress gives you the tools to avoid this. The hard part is using the right function for the right output context.

The rule that saves you

Don’t think “sanitize everything once and I’m done.”

Think in two phases:

  1. Sanitize on input when storing or processing data
  2. Escape on output based on where the data is going

That second step is where most plugin XSS bugs happen.

Common XSS sources in plugins

Treat all of these as untrusted:

  • $_GET, $_POST, $_REQUEST, $_COOKIE
  • shortcode attributes
  • REST API request parameters
  • AJAX request parameters
  • options from get_option()
  • post meta from get_post_meta()
  • user profile fields
  • taxonomy term names and descriptions
  • content coming from third-party APIs

Even data from admins can still become XSS. Stored XSS in an admin-only setting is still a real bug.

Output escaping: the functions you actually need

WordPress has context-specific escaping helpers. Use them.

HTML text nodes

Use esc_html() when output goes between tags.

echo '<p>' . esc_html( $message ) . '</p>';

Bad:

echo "<p>$message</p>";

HTML attributes

Use esc_attr() inside attributes.

echo '<input type="text" value="' . esc_attr( $value ) . '">';

Bad:

echo '<input type="text" value="' . $value . '">';

URLs

Use esc_url() for URLs you output.

echo '<a href="' . esc_url( $url ) . '">View report</a>';

If you’re saving a URL, sanitize with esc_url_raw() before storing.

$settings['redirect_url'] = esc_url_raw( $_POST['redirect_url'] ?? '' );

Limited HTML

If you intentionally allow some HTML, use wp_kses() or wp_kses_post().

echo wp_kses_post( $custom_message );

For a stricter allowlist:

$allowed = [
    'a' => [
        'href' => true,
        'title' => true,
        'target' => true,
        'rel' => true,
    ],
    'strong' => [],
    'em' => [],
    'br' => [],
];

echo wp_kses( $html, $allowed );

If you don’t need HTML, don’t allow HTML. esc_html() is simpler and safer.

JavaScript strings

If you’re placing data directly into inline JavaScript, use wp_json_encode() whenever possible.

Good:

echo '<script>';
echo 'const pluginConfig = ' . wp_json_encode( [
    'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    'label'   => $label,
] ) . ';';
echo '</script>';

Risky:

echo "<script>const label = '$label';</script>";

If you absolutely must escape a JS string manually, WordPress has esc_js(), but I strongly prefer wp_json_encode() because it avoids a lot of edge cases.

A real vulnerable admin page

This kind of code shows up constantly:

function my_plugin_render_settings_page() {
    $tab = $_GET['tab'] ?? 'general';

    echo '<div class="wrap">';
    echo '<h1>Settings - ' . $tab . '</h1>';
    echo '</div>';
}

An attacker can craft a URL that injects script into the admin page.

Fixed version:

function my_plugin_render_settings_page() {
    $tab = sanitize_key( $_GET['tab'] ?? 'general' );

    echo '<div class="wrap">';
    echo '<h1>Settings - ' . esc_html( $tab ) . '</h1>';
    echo '</div>';
}

A better version is to avoid reflecting raw values at all:

function my_plugin_render_settings_page() {
    $tab = sanitize_key( $_GET['tab'] ?? 'general' );

    $labels = [
        'general' => 'General',
        'advanced' => 'Advanced',
        'tools' => 'Tools',
    ];

    $label = $labels[ $tab ] ?? 'General';

    echo '<div class="wrap">';
    echo '<h1>' . esc_html( $label ) . ' Settings</h1>';
    echo '</div>';
}

That pattern is much harder to break.

Stored XSS in plugin settings

Here’s a classic settings bug:

function my_plugin_save_settings() {
    update_option( 'my_plugin_banner_text', $_POST['banner_text'] ?? '' );
}

function my_plugin_render_banner() {
    $text = get_option( 'my_plugin_banner_text', '' );
    echo '<div class="notice">' . $text . '</div>';
}

If an attacker gets malicious HTML stored there, it executes every time the banner renders.

Safer version:

function my_plugin_save_settings() {
    check_admin_referer( 'my_plugin_save_settings' );

    $text = sanitize_text_field( wp_unslash( $_POST['banner_text'] ?? '' ) );
    update_option( 'my_plugin_banner_text', $text );
}

function my_plugin_render_banner() {
    $text = get_option( 'my_plugin_banner_text', '' );
    echo '<div class="notice"><p>' . esc_html( $text ) . '</p></div>';
}

If you want to allow formatting:

function my_plugin_save_settings() {
    check_admin_referer( 'my_plugin_save_settings' );

    $allowed_html = [
        'a' => [
            'href' => true,
            'title' => true,
            'target' => true,
            'rel' => true,
        ],
        'strong' => [],
        'em' => [],
        'br' => [],
    ];

    $text = wp_kses( wp_unslash( $_POST['banner_text'] ?? '' ), $allowed_html );
    update_option( 'my_plugin_banner_text', $text );
}

function my_plugin_render_banner() {
    $text = get_option( 'my_plugin_banner_text', '' );
    echo '<div class="notice"><p>' . wp_kses_post( $text ) . '</p></div>';
}

Yes, that looks repetitive. Good. Security code should be obvious.

Attribute injection is still XSS

Developers often escape HTML text but forget attributes:

echo '<div data-name="' . $name . '"></div>';

If $name contains a quote, the attacker can break out of the attribute.

Fix it:

echo '<div data-name="' . esc_attr( $name ) . '"></div>';

Same for value, placeholder, title, aria-label, data-*, and hidden inputs.

Shortcodes are a common mess

Shortcode attributes are user-controlled.

Bad:

function my_card_shortcode( $atts ) {
    $atts = shortcode_atts( [
        'title' => '',
        'url'   => '',
    ], $atts );

    return '<a href="' . $atts['url'] . '">' . $atts['title'] . '</a>';
}

Safe:

function my_card_shortcode( $atts ) {
    $atts = shortcode_atts( [
        'title' => '',
        'url'   => '',
    ], $atts );

    return sprintf(
        '<a href="%s">%s</a>',
        esc_url( $atts['url'] ),
        esc_html( $atts['title'] )
    );
}

Different context, different escaping. Don’t use esc_html() for URLs and call it done.

AJAX handlers and JSON responses

A lot of plugin AJAX code manually concatenates HTML and sends it back.

Bad:

function my_plugin_search_ajax() {
    $term = $_GET['term'] ?? '';
    echo '<li>Results for ' . $term . '</li>';
    wp_die();
}

Better:

function my_plugin_search_ajax() {
    $term = sanitize_text_field( wp_unslash( $_GET['term'] ?? '' ) );

    wp_send_json_success( [
        'label' => sprintf( 'Results for %s', $term ),
    ] );
}

Then render safely in JavaScript using DOM APIs, not innerHTML.

fetch(ajaxurl + '?action=my_plugin_search&term=test')
  .then(r => r.json())
  .then(data => {
    const li = document.createElement('li');
    li.textContent = data.data.label;
    document.querySelector('#results').appendChild(li);
  });

If you return JSON and use textContent, you avoid a whole class of XSS bugs.

Inline scripts in admin pages

I try to avoid inline scripts entirely. If you need to pass PHP data to JavaScript, localize or inject JSON safely.

wp_enqueue_script(
    'my-plugin-admin',
    plugins_url( 'admin.js', __FILE__ ),
    [],
    '1.0.0',
    true
);

wp_add_inline_script(
    'my-plugin-admin',
    'window.MyPluginConfig = ' . wp_json_encode( [
        'nonce'   => wp_create_nonce( 'my_plugin_action' ),
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'screen'  => $screen_id,
    ] ) . ';',
    'before'
);

That is much safer than stitching together JavaScript strings by hand.

WordPress slashing gotcha

WordPress request data is slashed. If you sanitize raw $_POST without unslashing, you can end up with weird behavior.

Use wp_unslash() first:

$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );

I still see plugin code miss this all the time.

A simple review checklist

When I audit a plugin for XSS, I grep for these first:

  • echo $_GET
  • echo $_POST
  • print_r(
  • innerHTML
  • document.write
  • <script>
  • update_option(
  • get_option(
  • $_REQUEST
  • wp_ajax_

Then I ask one question at every sink: what escaping matches this output context?

CSP helps, but it won’t fix broken plugin code

A Content Security Policy can reduce XSS impact, especially by blocking inline script execution and limiting script sources. But CSP is backup, not the primary fix.

If you’re adding CSP to a WordPress app, keep your plugin code compatible with a stricter policy by avoiding inline scripts and event handlers like onclick. For implementation patterns, see CSP Guide.

The safe default mindset

If you’re unsure what to do:

  • plain text in HTML: esc_html()
  • attribute value: esc_attr()
  • URL: esc_url()
  • JSON for JavaScript: wp_json_encode()
  • limited trusted HTML: wp_kses() / wp_kses_post()

And for input:

  • text: sanitize_text_field()
  • keys: sanitize_key()
  • textarea: sanitize_textarea_field()
  • URL for storage: esc_url_raw()

The biggest WordPress plugin XSS mistakes are not exotic. They’re usually basic context failures. Fixing them means being strict and a little repetitive. That’s fine. Repetitive secure code beats clever vulnerable code every time.