Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 42 of 45

Writing secure WordPress code

The four habits of secure WordPress code: sanitise input, escape output, check nonces and capabilities, and prepare SQL, with working examples.

16 minSecurity, performance and projectUpdated 23 October 2026

By the end of this lessonHandle input and output in themes and plugins so they resist cross-site scripting, request forgery, privilege problems and SQL injection.

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:

  1. Sanitise or validate input when it comes in.
  2. Escape output when you print it.
  3. Check a nonce and the user's capability before doing anything that changes data.
  4. 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
<?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";
result
&lt;script&gt;alert(&quot;hacked&quot;)&lt;/script&gt;Sam
&quot;onmouseover=&quot;steal()
bad URL removed
https://example.com/a%20b?x=1&#038;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 &lt;), 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
<?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' );
result
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
<?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";
result
valid nonce: true
wrong action: false
forged value: false

In real code:

  • put the nonce in a form with wp_nonce_field( 'action', 'field_name' ), or in a link with wp_nonce_url( $url, 'action' );
  • verify it on the receiving side with wp_verify_nonce() or check_admin_referer( 'action', 'field_name' ) (which stops with an error if it is invalid);
  • for the REST API, the X-WP-Nonce header carries a wp_rest nonce, and wp.apiFetch sends 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
<?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";
}
result
admin: manage_options=true, publish_posts=true, edit this post=true
author: manage_options=false, publish_posts=true, edit this post=false

Check 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
<?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';
}
bash
wp plugin activate bakery-secure
result
Plugin 'bakery-secure' activated.
Success: Activated 1 of 1 plugins.
php
<?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";
result
visitor:  not allowed
admin, no nonce:    invalid nonce
admin, wrong object: not a recipe
admin, all checks:   deleted

The 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 include a path built from user input.
  • Redirects: use wp_safe_redirect(), not header( 'Location: ...' ), so an attacker cannot send users to another site.
  • Secrets: keep API keys in wp-config.php constants 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() where esc_url() or esc_attr() is needed.
  • Checking a nonce but not the capability (or the capability but not the nonce).
  • Trusting hidden form fields or $_REQUEST for 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:

php
$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.

Check yourself

1. What is the safest time to escape output?
2. Which function should you use for a value placed in an HTML attribute such as value="..."?
3. What does a nonce protect against?
4. A nonce is checked and the request is valid. What else must an admin action check?
5. How should a number from $_GET['id'] be handled before using it?