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.
| Validation | Sanitisation | Escaping | |
|---|---|---|---|
| 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 &, which renders as a literal
& 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&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.
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
& 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.