Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 09 30 MIN

Input and output

Escaping in Context: HTML, Attributes, URLs and JavaScript

Which escaping function belongs in which output context, the two traps that survive a correct function choice, and the printf pattern that keeps translations safe.

A reviewer finds an unescaped value on an order screen and the fix is three characters: esc_html() around the variable. The finding closes, the test passes, and the same pattern is now in the codebase as an example. Six months later the same screen gets a bug report that the customer's surname renders as O'Brien, and the reason is that a value stored escaped is now being escaped on its way out.

Both bugs come from the same place. Escaping is not a property you give a value, like "cleaned". It is an operation that belongs to a specific position in a specific document, and applying it in the wrong one does nothing useful, or does damage that shows up somewhere else.

This lesson is the map: which function goes with which position, what goes wrong when the map is ignored, and the formatting pattern that keeps translated strings from becoming the escape function's problem.

Read the output position before choosing a function

The function names tell you where each one belongs, and WordPress offers six that cover almost every position you will meet. Read the surrounding markup, then pick.

PositionFunctionWhat it does to the value
Between tags esc_html() Encodes &, <, >, single and double quotes, so nothing in the value can become markup.
Inside an attribute esc_attr() The same encoding, named for the position. Quote your attributes.
Inside href, src, action esc_url() Removes characters a URL cannot contain, resolves the protocol against the allowed list, and returns an empty string for anything that fails.
Inside <textarea> esc_textarea() Encodes the same characters with the site charset, for content that is text rather than markup.
Inside a <script> block or an event attribute wp_json_encode(), then esc_js() if it is a string literal Turns PHP data into a JavaScript literal that cannot terminate the block it sits in.
Where a controlled subset of HTML is wanted wp_kses_post() Keeps allowed elements and attributes, removes the rest, and is safe to run more than once.

Every value below is passed through one of those functions on PHP 8.5.6 with WordPress 7.0.7, so the output is what you will see, not a recollection of it.

esc_html( '<b>x</b>' );
// '&lt;b&gt;x&lt;/b&gt;'

esc_attr( 'a" b\' c' );
// 'a&quot; b&#039; c'

esc_url( 'javascript:alert(1)' );
// ''

esc_url( 'https://example.test/?a=1&b=2' );
// 'https://example.test/?a=1&#038;b=2'

esc_textarea( '</textarea><script>x</script>' );
// '&lt;/textarea&gt;&lt;script&gt;x&lt;/script&gt;'

esc_js( '</script><script>alert(1)</script>' );
// '&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;'

wp_json_encode( array( 'label' => '</script><script>alert(1)</script>' ) );
// '{"label":"<\/script><script>alert(1)<\/script>"}'

Two entries deserve a longer look. esc_url( 'javascript:alert(1)' ) returns an empty string rather than a cleaned URL, because esc_url() compares the protocol against the allowed list and gives up when it does not match. An empty href is a link that goes nowhere, which is the correct outcome for a link that should not exist. The ampersand line shows the opposite behaviour: a legitimate query string comes back with its separator written as &#038;. That is still an ampersand to the browser and it is the reason a URL should never be re-escaped by hand before it is printed.

The last line is the one to memorise if you write any JavaScript. wp_json_encode() escapes forward slashes, so the sequence </script> leaves the function as <\/script>. A browser looking for the end of a script block does not find one, and the rest of your script runs instead of the visitor's.

Why esc_html inside an attribute is not automatically safe

On WordPress 7.0.7, esc_html() and esc_attr() produce the same output for the same input: both call the same encoder with quotes included. That is not a reason to use either one anywhere. The functions differ in the promise they make, and the promise is the part a review depends on.

An attribute is a different grammar from element text, and the character that matters most in that grammar is the space. Take a value that contains one, put it in an attribute you did not quote, and the parser does the rest.

$value = 'x onmouseover=alert(1)';

<input value="&lt;?php echo esc_html( $value ); ?&gt;">
  parsed: value = "x onmouseover=alert(1)"      one attribute

<input value=&lt;?php echo esc_html( $value ); ?&gt;>
  parsed: value = "x"
          onmouseover = "alert(1)"              two attributes

Escaping the value did not create the second attribute. Leaving the attribute unquoted did, and the escape function has no way to prevent it, because a space is a perfectly ordinary character in element text and in a quoted attribute. The fix in the markup is the quotes. The fix in the review is to read the position rather than the function name, since a codebase that reaches for esc_html() everywhere will eventually place it in a position where the promise does not hold.

The clearest case is a script block, where the rules are different again. Inside <script> the browser does not decode HTML entities, so esc_html( "it's" ) puts the literal characters &#039; into a JavaScript string. Your code compares against it's and never matches. And esc_html() does not escape the backslash, so a value that ends in one produces a JavaScript string that swallows the closing quote and turns the next token into code.

// Wrong: entities inside a script block are literal characters to JavaScript.
printf( "<script>var label = '%s';</script>", esc_html( $label ) );

// Right for structured data.
printf( "<script>var acme = %s;</script>", wp_json_encode( $config ) );

// Right for a single value passed into a function.
printf( '<button onclick="acme(%s)">Go</button>', esc_js( $message ) );

The middle line is the pattern to prefer. Passing a whole array through wp_json_encode() gives you one literal that holds every value you need, with every quote and slash already handled, and it removes the temptation to interpolate PHP variables into a script one at a time.

Escaping too early is a one-way door

The second trap is harder to spot in review because the code looks diligent. A value is escaped as it is saved, and then escaped again as it is printed. The first escape is the mistake, and the symptom appears weeks later as text that displays with visible ampersands.

$raw   = 'Tom & Jerry';
$once  = esc_html( $raw );                                  // 'Tom &amp; Jerry'
$twice = htmlspecialchars( $once, ENT_QUOTES, 'UTF-8', true ); // 'Tom &amp;amp; Jerry'

esc_html() does not double-encode on its own, which is why this bug is easy to create and hard to recognise. It passes double_encode = false to htmlspecialchars(), so an existing &amp; is left alone. The double encoding arrives when a second mechanism joins in: a second escape with double-encoding enabled, a value read from a REST response that is already encoded, or a template that escapes a value a filter has already escaped. The output &amp;amp; renders as &amp;, and the person who reported the bug describes it as "our data has ampersands in it".

The most common version does not involve escaping twice at all. It involves escaping somewhere the browser is not the consumer.

// The option is stored with the encoded value...
update_option( 'acme_webhook_path', esc_html( '/hooks/receive?a=1&b=2' ) );
// ...which is now '/hooks/receive?a=1&amp;b=2' in the database.

// A year later the signature check compares the request path to the option.
$expected = get_option( 'acme_webhook_path' );      // never matches the request
if ( $expected !== $request_path ) {
    return new WP_REST_Response( 'bad signature', 403 );
}

Escaping on the way into storage encodes a value for a browser that will never see it. Every consumer of that option now has to decode it first, and the ones that do not are silently broken. The rule that prevents it is positional: escaping functions belong at the point where HTML, JSON or JavaScript is produced, and nowhere else.

WordPress has one case where the encode-on-save pattern is deliberate, and it is worth knowing so the rule does not get applied wrongly. Post content is stored with its text entities encoded, so a post containing &amp; in the editor round-trips through wp_kses_post() for display and through the editor for editing. The two operations are not interchangeable at that boundary:

// Display path for stored post content, on WordPress 7.0.7.
wp_kses_post( '<p>Tom &amp; Jerry</p>' );   // '<p>Tom &amp; Jerry</p>'

// The same input through an escaper.
esc_html( '<p>Tom &amp; Jerry</p>' );        // '&lt;p&gt;Tom &amp;amp; Jerry&lt;/p&gt;'

If the second line is what you see on a page, the markup is being treated as a string rather than as content, and the fix is to stop escaping a value that is already stored in the format it is displayed in. Where a value should be plain text, sanitise it on save so it is genuinely plain, and escape it on output without exception.

printf with %s keeps translation and escaping in the same place

Translated strings are the part of output that breaks the positional rule most often, because the natural way to write a message is to concatenate the pieces.

// Wrong: the translator cannot reorder the sentence, and the variable is unescaped.
echo __( 'Results for', 'acme' ) . ' ' . $term;

// Wrong for a subtler reason: esc_html() is applied to the translated string, so an
// argument containing markup is escaped too late to be useful.
echo esc_html( sprintf( __( 'Results for %s', 'acme' ), $term ) );

// Right: the placeholder survives translation, and the argument is escaped once.
printf(
    /* translators: %s: the search term the visitor typed. */
    esc_html__( 'Results for %s', 'acme' ),
    esc_html( $term )
);
// Prints: Results for shoes

The difference between the second and third versions is the order of two operations. sprintf() builds the string and then escaping runs over the result, so anything the argument contributes is treated as text to encode. In the third version the argument is escaped on its own, and the translated template goes through esc_html__(), which escapes only the template. A translator who needs to keep the sentence intact in their language can move the %s, because printf() places it after translation.

The translator comment is not decoration. %s carries no meaning on its own, and without the comment a translator has to guess whether the placeholder is a name, a number or a fragment of HTML. Use %s rather than %1$s while the string has one placeholder, and reach for the numbered form when a sentence has two, since reordering is exactly what numbered placeholders exist for.

The same argument applies to attributes and to scripts, with the escaping function swapped for the context and the i18n function swapped for its escaped counterpart: esc_attr__( 'Open settings', 'acme' ) inside an attribute, esc_html__( 'Open settings', 'acme' ) between tags. Both take the text domain as the second argument.

When wp_kses_post is the right answer and when it is not

Sometimes a value is meant to contain markup: a post excerpt, a product description, a block attribute that holds rich text. Escaping it as text would show the tags to the reader. wp_kses_post() is the function for that position, and it works because it removes rather than encodes.

wp_kses_post( '<p>a <strong>b</strong></p><script>alert(1)</script>' );
// '<p>a <strong>b</strong></p>alert(1)'

wp_kses_post( '<img src=x onerror=alert(1)>' );
// '<img src="x">'

Two things about that output matter at the point of rendering. The script element is gone but the text it contained remains, so this is not a boundary that removes a hostile string; it is a boundary that removes the dangerous structure and leaves content that is still attacker-influenced. And it is idempotent, so content that has already been through it can be passed through again by a later filter without being damaged.

Use it where the value is genuinely rich text and the surrounding template is not doing its own escaping. Do not use it as a general-purpose wrapper on plain data, because it will not encode the thing you need encoded: an attribute value or a JavaScript string that has gone through wp_kses_post() is still a string with quotes in it.

Lab 30 minutes

Below is a template fragment from a fictional plugin. It renders a customer's display name from the database, a link the customer supplied, a support message stored as rich text, and a configuration array the page's JavaScript needs. Four values, four positions, and every one of them is wrong.

<h2>&lt;?php echo $display_name; ?&gt;</h2>
<a href=&lt;?php echo $profile_url; ?&gt;>Profile</a>
<p>&lt;?php echo $support_message; ?&gt;</p>
<script>var acmeConfig = &lt;?php echo json_encode( $config ); ?&gt;;</script>
<input type="text" name="nickname" value=&lt;?php echo $display_name; ?&gt;>

Rewrite it so every position has the function that belongs there, and give the <h2> line a printf() form with a translator comment.

Then answer three questions. Which line would still be exploitable if you only added esc_html() to all five? Which line produces a visible &amp; to the reader if the value was stored escaped? And which line needs a value that is not a string at all?

Show the solution and the reasoning
<h2>
    &lt;?php
    printf(
        /* translators: %s: the customer's display name. */
        esc_html__( 'Messages for %s', 'acme' ),
        esc_html( $display_name )
    );
    ?&gt;
</h2>

<a href="&lt;?php echo esc_url( $profile_url ); ?&gt;">Profile</a>

<p>&lt;?php echo wp_kses_post( $support_message ); ?&gt;</p>

<script>var acmeConfig = &lt;?php echo wp_json_encode( $config ); ?&gt;;</script>

<input type="text" name="nickname" value="&lt;?php echo esc_attr( $display_name ); ?&gt;">

The line that stays exploitable after adding esc_html() everywhere is the href. Escaping a hostile URL for HTML produces a perfectly clickable javascript: link, because encoding changes how the URL is written down and not what a browser does with it. Only esc_url() refuses the protocol and returns an empty string. That is the whole argument for choosing by position: the function has to know what the surrounding grammar does with the value, and esc_html() does not.

The line that shows visible &amp; is the <h2>, if the display name was stored escaped. The name O'Brien stored as O&#039;Brien comes back out through esc_html() as O&amp;#039;Brien and the reader sees the second form. The fix is upstream: the save path should have used sanitize_text_field() and left the encoding to this template. The same reasoning covers the value attribute, which is why it now uses esc_attr() on the same raw value.

The line that needs something that is not a string is the last one. wp_json_encode() exists for arrays and objects, and the reason to pass the whole configuration through it in one call is that every value inside is encoded with the same rules. Building the literal by hand, one concatenated variable at a time, is how an unescaped quote ends up closing the string and turning the rest of the config into code.

The support message is the judgement call, and either answer is defensible. If the field is meant to hold rich text, wp_kses_post() is the reading that matches the intent, and the residual risk is the one described above: allowed structure, attacker-influenced content, still worth reviewing when the content is displayed to a user with more privileges than the one who wrote it. If the field is meant to hold a plain sentence, esc_html() is the stricter and simpler choice, and the right place to enforce it is the sanitise callback on save, not the template.

Progress

Mark this lesson done and it stays marked in this browser.