2026年11月14日
2026年11月14日
WordPress XSS (Cross-Site Scripting) Protection: Escaping and wp_kses
This article is a localized edition of the Japanese original. Read the Japanese original
XSS (cross-site scripting) is a vulnerability where a string supplied by an attacker is output as HTML as is, so a script runs in a visitor's or administrator's browser. If a script runs while an administrator is logged in, it can perform anything the administrator can do, such as adding users or changing settings, without their knowledge.
WordPress core provides a full set of escaping functions, but they cannot protect you if your theme or plugin code does not use them. Patchstack's reports on WordPress vulnerabilities also say that most newly found vulnerabilities are in plugins, and that XSS is the most common type. This article explains how to avoid introducing XSS in your own themes, plugins and functions.php customizations.
Main types of XSS
| Type | How it happens | WordPress example |
|---|---|---|
| Reflected | A URL parameter or form value is output directly on the page | Showing the search term with echo $_GET['q']; |
| Stored | A value saved in the database is output every time the page is displayed | Displaying a custom field or plugin setting without escaping |
| DOM-based | JavaScript inserts a value from the page as HTML with innerHTML or similar |
Putting the part of the URL after # into innerHTML |
In every case, the cause is "untrusted data was output without being neutralized in the way that fits where it is output".
The basic idea: sanitize input, escape output
The WordPress developer documentation treats these two as separate steps.
- Sanitizing input: Before you save or process a value you received, turn it into the expected form (a number for a number; for single-line text, remove line breaks and tags).
- Escaping output: Right before outputting to HTML, pass the value through the function that fits where it goes (body text, attribute, URL, JavaScript). The official documentation recommends escaping as late as possible, at the moment of output.
Values saved in the database may have been changed by another plugin or an earlier bug, so escape them again when you output them.
Step 1: Use the escaping function that fits the output context
| Where it is output | Function to use |
|---|---|
| HTML body (text inside tags) | esc_html() |
HTML attribute values (value, class, title and so on) |
esc_attr() |
URLs such as href and src |
esc_url() |
Contents of <textarea> |
esc_textarea() |
| Values allowed to contain HTML (same tags as post content) | wp_kses_post() |
| You choose the allowed tags | wp_kses() |
| Passing a value to JavaScript | wp_json_encode(), or esc_js() for inline JavaScript |
There are also versions combined with translation functions: esc_html__(), esc_html_e(), esc_attr__() and esc_attr_e().
<?php
// HTML body
// Bad: echo $_GET['q'];
$q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
echo '<p>Search term: ' . esc_html( $q ) . '</p>';
// HTML attribute value
// Bad: echo '<input type="text" name="q" value="' . $q . '">';
echo '<input type="text" name="q" value="' . esc_attr( $q ) . '">';
// URL (disallowed protocols such as javascript: are removed)
$url = get_post_meta( get_the_ID(), 'related_url', true );
echo '<a href="' . esc_url( $url ) . '">Related link</a>';
// Output a translated string
esc_html_e( 'Read more', 'my-theme' );
esc_html() turns < and > into < and >, so if you use it on a value containing HTML tags, the tags are shown as plain text. For values whose tags you want to keep, use wp_kses_post() or wp_kses() (Step 2).
Passing values to JavaScript
To pass a PHP value to JavaScript, do not build a <script> by concatenating strings; use wp_add_inline_script() and wp_json_encode(). wp_json_encode() converts the value into valid JSON and escapes / by default, so a </script> inside the value cannot close the <script> early.
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script( 'my-script', get_theme_file_uri( 'js/my-script.js' ), array(), '1.0.0', array( 'in_footer' => true ) );
$data = array(
'userName' => wp_get_current_user()->display_name,
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
);
// Bad: echo '<script>var name = "' . $name . '";</script>';
wp_add_inline_script( 'my-script', 'const myData = ' . wp_json_encode( $data ) . ';', 'before' );
} );
When you put JSON into an HTML attribute (such as a data- attribute), also apply attribute escaping, as in esc_attr( wp_json_encode( $data ) ). On the JavaScript side, do not insert received values with innerHTML; use textContent.
Step 2: For values that allow HTML, choose the allowed tags with wp_kses
To allow only some of the HTML a user enters, pass wp_kses() a list of allowed tags and attributes. Tags and attributes not in the list, and dangerous URLs such as javascript:, are removed.
<?php
$allowed_html = array(
'a' => array(
'href' => true,
'title' => true,
'target' => array( 'values' => array( '_blank' ) ), // Allow only _blank for target
'rel' => true,
),
'br' => array(),
'em' => array(),
'strong' => array(),
'p' => array( 'class' => true ),
'ul' => array(),
'ol' => array(),
'li' => array(),
'code' => array(),
);
$bio = get_user_meta( $user_id, 'custom_bio', true );
echo wp_kses( $bio, $allowed_html );
// Allow the same tags as post content
echo wp_kses_post( $custom_html );
// Strip all tags and keep only the text
echo esc_html( wp_strip_all_tags( $bio ) );
Sanitizing that WordPress core already does
When posts and comments are saved, WordPress automatically sanitizes them with wp_kses depending on the user's capabilities.
- For users without the
unfiltered_htmlcapability (Authors, Contributors, Subscribers and so on), tags not allowed in posts are removed from the post content and excerpt they save. Comments from users without this capability are also limited to the tags allowed in comments. - On a single site, only Administrators and Editors have
unfiltered_html; on multisite, only Super Admins. With this capability, users can save HTML that includes scripts, so do not make untrusted people Editors. - If you do not want Administrators and Editors to have this capability either, add
define( 'DISALLOW_UNFILTERED_HTML', true );towp-config.php.
So you do not need to add the same sanitizing again yourself with the content_save_pre filter or similar. Sanitize and escape the values that do not go through WordPress core's mechanisms yourself, such as your own forms, custom fields and plugin settings.
Step 3: Sanitize input when you receive it
When you receive values such as form input, remove the backslashes WordPress adds with wp_unslash(), then sanitize with the function that fits the kind of value.
<?php
// Single-line text (removes tags and line breaks)
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
// Multi-line text
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
// Email address
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
// Non-negative integer (IDs and similar)
$post_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
// URL to be saved
$site = isset( $_POST['site'] ) ? esc_url_raw( wp_unslash( $_POST['site'] ) ) : '';
Even after sanitizing, do not skip escaping on output (Step 1).
Nonces protect against CSRF
Adding wp_nonce_field() to a form and checking it on the receiving side with wp_verify_nonce() or check_admin_referer() protects against attacks that make users submit requests from other sites (CSRF). Nonces do not prevent XSS, but like XSS protection, they are essential for admin screens and form handling.
Step 4: Audit your code with PHPCS
Hunting for missing escaping by eye is hard. With PHP_CodeSniffer (PHPCS) and the WordPress Coding Standards (WPCS), you can list output that is not escaped and input that is not sanitized. Run it against your theme or plugin folder in a development environment where Composer is available.
# Install WPCS in a working folder
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev wp-coding-standards/wpcs:"^3.0"
# Check only the security-related rules
vendor/bin/phpcs --standard=WordPress \
--sniffs=WordPress.Security.EscapeOutput,WordPress.Security.ValidatedSanitizedInput,WordPress.Security.NonceVerification \
wp-content/themes/my-theme/
A line such as echo $_GET['q']; gets reports like "output is not escaped", "input is not sanitized" and "not passed through wp_unslash()". Fix the reported lines using the patterns in Steps 1 to 3.
Step 5: Limit the damage with headers (CSP)
Fixing the code is the foundation, but a Content-Security-Policy (CSP) header that restricts where the browser may load scripts from can reduce the damage if an XSS bug exists.
<?php
add_action( 'send_headers', function () {
// Start with Report-Only, which only reports violations in the browser console
header( "Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'" );
// Stop MIME type sniffing
header( 'X-Content-Type-Options: nosniff' );
} );
- WordPress core and plugins output many inline
<script>tags. If you enforce onlyscript-src 'self', the admin area or block rendering may stop working, so check violations withContent-Security-Policy-Report-Onlyfirst and then decide what to allow. - Adding
'unsafe-inline'toscript-srcallows all inline scripts and greatly reduces the protection against XSS. If you need to allow inline scripts, you can use nonces. In WordPress 5.7 and later, thewp_script_attributesandwp_inline_script_attributesfilters let you add anonceattribute to<script>tags. - Headers added in
send_headersare sent only with pages WordPress outputs. To add them to static files such as images and CSS too, set them in the web server configuration.
Do not use the X-XSS-Protection header. It controlled the XSS filter built into older browsers. Chrome and Edge have removed that filter, and Firefox never supported it. MDN recommends using CSP instead, and OWASP recommends setting it to 0 (disabled) if you send it at all.
Check the headers you set like this.
curl -sI https://example.com/ | grep -iE "content-security-policy|x-content-type-options"
Confirm the fix
In a development or staging environment, enter strings such as <script>alert(1)</script> or "><img src=x onerror=alert(1)> into input fields and URL parameters, and confirm that the page source shows them as text, such as <script>, and that no dialog appears. Do not try this on a production site or on other people's sites.
Summary
- Escape values right before output with the function that fits the context (
esc_html(),esc_attr(),esc_url(),wp_kses()and so on) - Pass values to JavaScript with
wp_add_inline_script()andwp_json_encode() - Sanitize received values with the right function after
wp_unslash(). WordPress core sanitizes posts and comments when they are saved - Audit for missing escaping with PHPCS and the WordPress Coding Standards
- Start CSP in Report-Only mode, and do not use
X-XSS-Protection
Related articles
- How to set up a Content-Security-Policy (CSP) in WordPress
- How to customize the HTML tags allowed by wp_kses_post
- How to protect WordPress from CSRF and XSS
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)