The rules
Most WordPress security problems in themes and plugins come from the same four mistakes. Learn these four habits and you avoid nearly all of them:
- Sanitise or validate input when it comes in.
- Escape output when you print it.
- Check a nonce and the user's capability before doing anything that changes data.
- Use prepared statements for SQL.
Underneath all of them is one idea: anything that comes from outside your code is untrusted. That includes $_GET, $_POST, $_COOKIE, $_SERVER values, uploaded files, remote API responses, and content stored by users or other plugins.
Escaping output (against XSS)
Cross-site scripting (XSS) happens when an attacker's text is printed into a page as HTML and runs as script. The defence is to escape values as late as possible, by the context where they are printed:
| Context | Function |
|---|---|
| Text inside HTML | esc_html() |
| An attribute value | esc_attr() |
A URL (in href, src) |
esc_url() |
| JavaScript data | wp_json_encode() (or wp_localize_script) |
| Text with some allowed HTML | wp_kses_post() or wp_kses( $html, $allowed ) |
| Inside a textarea | esc_textarea() |
<?php
$name = '<script>alert("hacked")</script>Sam';
$url = 'javascript:alert(1)';
echo esc_html( $name ), "\n";
echo esc_attr( '"onmouseover="steal()' ), "\n";
echo esc_url( $url ) === '' ? "bad URL removed\n" : "kept\n";
echo esc_url( 'https://example.com/a b?x=1&y=2' ), "\n";
echo wp_kses_post( '<p>Fine <strong>bold</strong><script>alert(1)</script></p>' ), "\n";<script>alert("hacked")</script>Sam
"onmouseover="steal()
bad URL removed
https://example.com/a%20b?x=1&y=2
<p>Fine <strong>bold</strong>alert(1)</p>wp_kses_post() kept the harmless tags and dropped the <script> tag itself, leaving its text behind, so it is a filter for markup rather than a way to hide text. Each function turns dangerous characters into harmless ones for its context (< becomes <), or removes the value. Always match the function to where the value goes: esc_html() in an attribute, or esc_attr() in a link, is a bug. Escape when printing, not when saving, so each use is clearly safe. Translations have helpers that combine both: esc_html__( 'Text', 'domain' ) and esc_attr_e().
Sanitising and validating input
Validate means "is this what I expect?" (a number, an email, one of three values). Sanitise means "clean this so it is safe to store". Prefer validating against a known list when you can:
<?php
var_dump( sanitize_text_field( " <b>Hello</b>\n there " ) );
var_dump( absint( '-12abc' ) );
var_dump( sanitize_email( 'user@@example.com ' ) );
var_dump( is_email( 'user@example.com' ) );
var_dump( sanitize_key( 'My Key!' ) );
var_dump( sanitize_file_name( '../my report (1).php' ) );
var_dump( sanitize_textarea_field( "Line 1\nLine 2 <i>x</i>" ) );
// Allow-list: the safest check.
$level = 'impossible';
$allowed = [ 'easy', 'medium', 'hard' ];
var_dump( in_array( $level, $allowed, true ) ? $level : 'easy' );string(11) "Hello there"
int(12)
string(16) "user@example.com"
string(16) "user@example.com"
string(5) "mykey"
string(15) "my-report-1.php"
string(15) "Line 1
Line 2 x"
string(4) "easy"Request data from $_POST also has slashes added by WordPress, so call wp_unslash() first, then sanitise: sanitize_text_field( wp_unslash( $_POST['name'] ) ). Check that a value exists with isset() or ?? before using it.
Nonces (against CSRF)
Cross-site request forgery (CSRF) tricks a logged-in user's browser into sending a request they never intended, for example by loading an image whose address deletes a post. A nonce is a token tied to the user, the action and the time; only pages you built contain a valid one.
<?php
wp_set_current_user( 1 );
$nonce = wp_create_nonce( 'delete_recipe_12' );
echo "valid nonce: ", var_export( (bool) wp_verify_nonce( $nonce, 'delete_recipe_12' ), true ), "\n";
echo "wrong action: ", var_export( (bool) wp_verify_nonce( $nonce, 'delete_recipe_13' ), true ), "\n";
echo "forged value: ", var_export( (bool) wp_verify_nonce( 'abc123', 'delete_recipe_12' ), true ), "\n";valid nonce: true
wrong action: false
forged value: falseIn real code:
- put the nonce in a form with
wp_nonce_field( 'action', 'field_name' ), or in a link withwp_nonce_url( $url, 'action' ); - verify it on the receiving side with
wp_verify_nonce()orcheck_admin_referer( 'action', 'field_name' )(which stops with an error if it is invalid); - for the REST API, the
X-WP-Nonceheader carries awp_restnonce, andwp.apiFetchsends it for you.
Capabilities (against unauthorised use)
A nonce does not say the user is allowed; it only says the request came from your page. Always check the capability too:
<?php
$admin = 1;
$author = wp_insert_user( [ 'user_login' => 'ann', 'user_pass' => wp_generate_password(), 'role' => 'author' ] );
$post = wp_insert_post( [ 'post_title' => 'Admin only', 'post_status' => 'draft' ] ); // owned by user 0 / admin
foreach ( [ 'admin' => $admin, 'author' => $author ] as $label => $id ) {
echo $label, ': manage_options=', var_export( user_can( $id, 'manage_options' ), true ),
', publish_posts=', var_export( user_can( $id, 'publish_posts' ), true ),
', edit this post=', var_export( user_can( $id, 'edit_post', $post ), true ), "\n";
}admin: manage_options=true, publish_posts=true, edit this post=true
author: manage_options=false, publish_posts=true, edit this post=falseCheck a capability that matches what the action does: manage_options for site settings, edit_post (with the post ID) for editing a specific post, delete_user for deleting a user. Do not use role names like administrator for checks.
A complete, safe handler for a "delete" link, with all the checks in order:
<?php
/**
* Plugin Name: Bakery Secure
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// A function that deletes a recipe, only after every check passes.
function bakery_delete_recipe( int $post_id, string $nonce ): string {
if ( ! wp_verify_nonce( $nonce, 'bakery_delete_' . $post_id ) ) {
return 'invalid nonce'; // 1. the request came from our page
}
if ( ! current_user_can( 'delete_post', $post_id ) ) {
return 'not allowed'; // 2. this user may delete this post
}
if ( 'recipe' !== get_post_type( $post_id ) ) {
return 'not a recipe'; // 3. the object is what we expect
}
wp_trash_post( $post_id ); // 4. only now act
return 'deleted';
}wp plugin activate bakery-securePlugin 'bakery-secure' activated.
Success: Activated 1 of 1 plugins.<?php
register_post_type( 'recipe', [ 'public' => true, 'capability_type' => 'post' ] );
$recipe = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Rye', 'post_status' => 'publish' ] );
$page = 2;
wp_set_current_user( 0 );
echo "visitor: ", bakery_delete_recipe( $recipe, wp_create_nonce( 'bakery_delete_' . $recipe ) ), "\n";
wp_set_current_user( 1 );
echo "admin, no nonce: ", bakery_delete_recipe( $recipe, 'forged' ), "\n";
echo "admin, wrong object: ", bakery_delete_recipe( $page, wp_create_nonce( 'bakery_delete_' . $page ) ), "\n";
echo "admin, all checks: ", bakery_delete_recipe( $recipe, wp_create_nonce( 'bakery_delete_' . $recipe ) ), "\n";visitor: not allowed
admin, no nonce: invalid nonce
admin, wrong object: not a recipe
admin, all checks: deletedThe visitor was refused even though the nonce was valid, because a nonce says nothing about permission. Nonces made for logged-out visitors are valid too (they are tied to "no user"), which is one more reason never to rely on a nonce alone.
SQL and files
- SQL: build queries with
$wpdb->prepare()and%s,%d,%f(see The database and $wpdb). - Direct access: every PHP file starts with
if ( ! defined( 'ABSPATH' ) ) { exit; }. - Uploads: accept files with
wp_handle_upload()and the allowed types list, never move them yourself, and never let users choose where a file is saved or what extension it has. - File includes: never
includea path built from user input. - Redirects: use
wp_safe_redirect(), notheader( 'Location: ...' ), so an attacker cannot send users to another site. - Secrets: keep API keys in
wp-config.phpconstants or environment variables, never in the repository.
A habit: read the code as an attacker
For each place where code receives data, ask: where does this value come from, who can change it, what happens if it is <script>, '; DROP TABLE, ../../wp-config.php, a huge string, or an array when I expected text? Then check that the code handles it.
Common mistakes
- Escaping on save and then printing without escaping, or double-escaping.
- Using
esc_html()whereesc_url()oresc_attr()is needed. - Checking a nonce but not the capability (or the capability but not the nonce).
- Trusting hidden form fields or
$_REQUESTfor user IDs and prices. - Building SQL strings with user input.
extract( $_POST )and similar, which lets requests overwrite your variables.
Exercise
The line echo '<a href="' . $_GET['next'] . '">Continue</a>'; is in a theme. List what is wrong and write the safe version.
Show one possible answer
It prints a request value into a URL attribute with no escaping, so ?next=javascript:... or a " that closes the attribute allows script injection. Safe version:
$next = isset( $_GET['next'] ) ? esc_url_raw( wp_unslash( $_GET['next'] ) ) : home_url( '/' );
echo '<a href="' . esc_url( $next ) . '">' . esc_html__( 'Continue', 'my-theme' ) . '</a>';For redirects, use wp_validate_redirect() or wp_safe_redirect() so the value cannot point to another site.
Key points
- Treat everything from outside as untrusted: sanitise or validate input when it arrives and escape output when it is printed (late escaping).
- Use esc_html(), esc_attr(), esc_url() and wp_kses_post() for output, and sanitize_text_field(), absint(), sanitize_email() for input.
- A nonce proves a request came from your form or link; a capability check proves the user is allowed. You need both for any action that changes data.
- Use $wpdb->prepare() for SQL, never trust $_GET, $_POST or hidden form fields, and keep files from running by checking ABSPATH.