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

Input and output

Validation, Sanitisation and Escaping

How validation, sanitisation and escaping differ, where each one belongs in a request, and why a clean value in the database is still attacker input at render time.

A support agent opens an order and reads the customer's note. The note was written three weeks ago, passed through sanitize_text_field(), and stored. Nothing about the path is surprising, which is the problem: it looks correct to every reviewer who has read it, and the admin screen that renders the note builds HTML around a string that came from outside the site.

Three words describe what that path did and did not do. It sanitised on the way in. It never validated. It does not escape on the way out. The three get confused because all of them operate on strings and all of them make a value look different afterwards, but each is a separate decision with a separate failure mode.

Validation rejects; sanitisation cleans; escaping encodes

Validation is a decision. It inspects a value and answers yes or no. If the answer is no, the value does not go any further: the request is refused, the field is reported as invalid, or a default is used. Validation is where you notice that a tracking number is forty kilobytes long and stop.

Sanitisation is a transformation. It takes a value of any shape and returns a value of the shape the destination needs, discarding what does not fit. It never refuses anything. Ask sanitize_email() for an opinion on not an email and it hands back an empty string, which the calling code has to notice.

Escaping is also a transformation, and its purpose is narrower: it makes a value safe to place in one specific output context, and it changes nothing about the value's meaning for anybody else. An escaped string is for the browser that is about to parse it. It is not storage format and not a substitute for the other two.

ValidationSanitisationEscaping
Question May I act on this? What is the cleanest version of this? How do I put this here without changing the markup around it?
Output on refusal An error, a default, or a dropped record Never refuses; returns the cleaned value Never refuses; returns an encoded value
Boundary On the way in, at the handover On the way in, for the destination On the way out, for the context
A second call changes anything? No, the answer is the same Usually harmless, sometimes lossy Yes, and wrongly: the value gets encoded twice

The last row is the fastest way to tell them apart while reading code. Running a validator twice is free. Running an escaper twice is a bug.

A boundary check is validation, and it happens before the value reaches anything

Validation belongs where a value crosses from a party you do not control into code that assumes a shape. In a WordPress request that is the moment the value leaves the superglobal, not the moment you first use it. A tracking number validated three lines above a database query has been acting like a tracking number for those lines, and anything it passed through assumed something you did not check.

Validation also has to run on the server. A maxlength attribute, a type="number" input and a line of JavaScript before submission are conveniences for whoever fills the form in. None of them constrains a request built with curl.

The shape of the check depends on the field and is usually smaller than people expect. A tracking number might be eight to forty characters from a known alphabet. An email address might go through is_email() rather than sanitize_email(), because is_email() answers with a boolean and sanitize_email() answers with a string that may be empty.

// Fragment of a fictional plugin. Every function below is WordPress core.
$raw  = $_POST['tracking_number'] ?? '';
$raw  = is_string( $raw ) ? wp_unslash( $raw ) : '';
$trim = trim( $raw );

if ( 1 !== preg_match( '/^[A-Z0-9-]{8,40}$/', $trim ) ) {
    return new WP_Error( 'acme_tracking', 'A tracking number is 8 to 40 characters: A-Z, 0-9 and hyphens.' );
}

$tracking_number = $trim; // Validated. Safe to look up, log and store.

Three details carry the idea. The is_string() guard exists because a query string can send tracking_number[]=, and $_POST['tracking_number'] is then an array. The wp_unslash() call exists because WordPress adds slashes to request data before your code runs. And the function returns before storing anything, because validation that reports a problem and stores the value anyway is a log line, not a check.

Sanitisation depends on the destination, not on the value

There is no such thing as sanitising a string. There is sanitising it for somewhere specific, and choosing the function is choosing the destination.

sanitize_text_field() is for a single line of plain text: a label, a name, a subject. It strips tags, collapses runs of whitespace to one space, removes percent-encoded sequences, and throws away invalid UTF-8. A value that was <script> comes back empty, and so does a value that was an array. It also means a part number somebody typed as %41%42 arrives in the database as nothing at all, which is the kind of quiet data loss a support ticket gets filed about.

sanitize_email() is for an address heading into an email column: invalid characters are removed from the local part, invalid subdomains are dropped, and anything that still does not look like an address becomes an empty string. absint() is for an identifier or a count going into an integer column. It takes any scalar and returns a non-negative integer, so absint( '12abc' ) is 12 and absint( '-42' ) is 42. For a value that must not be negative, the function has silently rewritten the input rather than reporting it, and the site then behaves as though a record belonged to the wrong row.

HTML is the case where sanitisation has to be told what "clean" means. wp_kses() takes the content and an allowlist of elements, attributes and protocols, and returns the content with everything else removed. wp_kses_post() is that call with the allowlist WordPress uses for post content. Both are idempotent, which is unusual and useful: running wp_kses_post() on content that has already been through it returns the same string.

// Actual output from WordPress 7.0.7 on PHP 8.5.6.
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">'

wp_kses_post( '<a href="javascript:alert(1)" onclick="x">link</a>' );
// '<a href="">link</a>'

// A narrower allowlist is the same call with your own array.
wp_kses( '<p class="lead" onclick="x">a</p><em>b</em>', array( 'p' => array( 'class' => true ) ) );
// '<p class="lead">ab'

Read the first result twice. The script element is gone and the text inside it is not: the function removes tags, not the content between them. That is correct for a post body and wrong as a security boundary on its own, because the surviving text still has to be escaped when it is rendered. The second result shows an event attribute removed and a src left in place. The third shows a javascript: URL reduced to an empty href.

The custom allowlist is worth reading closely. <em> was not in the array, so the tag was removed and its text kept, and the onclick attribute was removed because attributes only survive when the allowlist names them. Building an allowlist is deciding what a browser will be permitted to execute later.

Escaping belongs at the point of rendering, and the database is not a trusted source

The tempting argument for sanitising instead of escaping goes like this: nothing reaches the database unless it has been cleaned, so anything read back is clean, so rendering it is safe. Every step of that is reasonable and the conclusion is still wrong, for two reasons.

The first is that the database is written by more than one code path. A migration, an import script, a REST endpoint added last year, a WP-CLI command: all of them write rows, and only the paths you have read apply your sanitiser. Data that arrives in a table without passing through your save handler has an unknown history.

The second reason is stronger, because it survives a perfectly disciplined site. Sanitisation is performed for one context and the read happens in another. A customer note stripped of tags on save is plain text in a column, and at the order screen that plain text is placed inside HTML your code is building. The value did not change; the requirement did. Between the write and the read there is usually a privilege change too, since the customer who wrote the note is not the administrator reading it.

So escaping is not a second layer of the same defence. It is the only layer that knows what the value is about to be embedded in, and it has to run at every rendering site.

Escaping is not idempotent

Escaping a value and then storing the escaped form is a bug with a long fuse. The second escape turns & into &amp;, which renders as a literal &amp; on screen. The next lesson covers it in full.

Escaping a value that gets compared is a different kind of bug

Not every problem with esc_html() at the wrong moment is a double-encoding problem. Sometimes the string is not rendered at all and the escaping has destroyed the only copy you had.

Consider a settings page that stores a webhook path and compares it later. If the save path runs esc_html() before writing the option, the stored value is /hooks/receive?a=1&amp;b=2. The comparison against the incoming request path fails forever, the requests never match, and the debugging starts from the assumption that the webhook configuration is at fault. The value on screen looks almost right, which is what makes it slow to find.

The same mistake appears in slugs, file paths, codes the user is asked to retype, and anything compared against a value that did not go through the same encoder. The question to ask before escaping is what the value is being embedded in. If the answer is HTML, use an escaping function. If the answer is a comparison, a database column, a URL requested by something other than a browser, or a log line, escaping is the wrong tool and sanitisation for the destination is the right one. The discipline that follows is to escape as late as possible, in the template, on the value as it is printed.

One request passes through the three verbs in a fixed order

A value arrives, validation decides whether it is acceptable, sanitisation prepares it for storage, and escaping prepares it for whichever screen reads it next.

request  ──▶  validation      rejects or passes     ← only place a refusal can happen
          │
          └▶  sanitisation    shaped for storage    ← destination-specific
                 │
              database        untrusted on read
                 │
              escaping         shaped for output    ← context-specific, per rendering site

Two consequences fall out of that diagram. Validation is the only step that can say no, so a value that skipped it has been accepted rather than checked. And escaping happens after storage, so nothing that happened before storage can replace it.

Lab 30 minutes

Write a function that takes one $_POST field as it arrives and returns either a validated value or a WP_Error. Use acme_part_number: uppercase letters, digits and hyphens, six to twenty characters, required.

Then write the save path for it with the sanitiser you would choose for a varchar(20) column, and a render line that prints it inside a <td> and again inside an value="..." attribute.

Answer three questions when the code runs. What does your validator do with acme_part_number[]=x? What happens to a part number that is already stored with an ampersand in it? And which of the three lines would still be correct if the row had been written by an import script that never called your validator?

Show the solution and the reasoning

The validator is deliberately boring. The interesting decisions are about where it sits and what it returns.

/**
 * Validate one POSTed part number.
 *
 * @return string|WP_Error The part number, or the reason it was refused.
 */
function acme_part_number_from_post() {
    $raw = $_POST['acme_part_number'] ?? null;

    // Arrays, objects and null all fail here. A cast would have accepted them.
    if ( ! is_string( $raw ) ) {
        return new WP_Error( 'acme_part_type', 'The part number must be a single value.' );
    }

    // WordPress slashes request data on arrival; unslash before inspecting it.
    $value = trim( wp_unslash( $raw ) );

    if ( '' === $value ) {
        return new WP_Error( 'acme_part_missing', 'A part number is required.' );
    }

    if ( ! preg_match( '/^[A-Z0-9-]{6,20}$/', $value ) ) {
        return new WP_Error( 'acme_part_format', 'Use 6 to 20 characters: A-Z, 0-9 and hyphens.' );
    }

    return $value;
}

// Save path: the sanitiser is chosen for a varchar(20) plain-text column.
$part = acme_part_number_from_post();

if ( is_wp_error( $part ) ) {
    // Stop here. Do not store a fallback and do not render the raw value.
    return $part;
}

update_post_meta( $post_id, '_acme_part_number', sanitize_text_field( $part ) );

// Render path: two contexts, two escapes, on the stored value as it is read.
printf( '<td>%s</td>', esc_html( $stored ) );
printf( '<input type="text" value="%s">', esc_attr( $stored ) );

The array case is the one people miss. acme_part_number[]=x makes $_POST['acme_part_number'] an array of one string, and every step after that either warns, coerces, or stores the string Array. The is_string() test catches it before anything downstream has an opinion, and the WP_Error says the request was malformed rather than merely empty.

The ampersand question is where sanitisation and escaping separate. If a part number containing an ampersand is already in the table, your validator never saw it and cannot help you. The render lines are what keep it from being markup: esc_html() turns it into &amp; in the cell, and esc_attr() does the same inside the attribute. Neither function cares where the value came from, which is the property you want on the output side.

The import script is the reason the render lines exist. If an importer wrote a value containing a quote and an angle bracket, the escaping at the point of output is all that stands between it and the browser. The validator is a control on one write path; the escapes are the control on every read path. If your answer relied on sanitize_text_field() to make the render lines safe, the import case is what breaks it.

Progress

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