Security for Developers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 8.4 and WordPress Coding Standards (WPCS) 3.4.1, and checked on 2026-10-28. WordPress 7.1 requires PHP 7.4 at minimum, and PHP 8.3 or newer is recommended; WordPress 7.2 (beta, due December 2026) is not expected to change anything here. All output comes from a local site (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, WP-CLI 2.12.0) at http://lumenpress.test:8080; the attacks are minimal demonstrations to run only against a disposable site of your own.

This is the developer’s side of WordPress security; the administrator’s side (HTTPS, two factors, brute-force limits, file permissions, headers, incident response) is in Security Hardening. The examples extend lumen-catalog, the catalogue plugin of the fictitious publisher Lumen Press, built in Plugin Fundamentals.

Why extension code is the weak point

WordPress core has a security team, coordinated disclosure and automatic minor releases backported to older branches. Plugins and themes run with the full privileges of core (any PHP in an active plugin can read the database, write files and act as any user) without that process, and almost every WordPress compromise starts in their code. The flaws fall into a handful of patterns, mapped here to the OWASP Top 10 (2025 edition):

OWASP category Typical plugin flaw, and the WordPress defense

A01 Broken Access Control

A handler that only checks login or is_admin(), a missing nonce (CSRF), SSRF, open redirects: current_user_can(), nonces, wp_safe_remote_get(), wp_safe_redirect()

A05 Injection

Variables concatenated into SQL, values printed unescaped (XSS): $wpdb->prepare(), late escaping

A06 Insecure Design

Uploads kept with the client’s extension: wp_handle_upload() with a restricted mimes list

A08 Data Integrity Failures

unserialize() of untrusted data (PHP object injection): exchange JSON

A03 Supply Chain Failures

Bundled vulnerable libraries: pinned, audited Composer dependencies (Composer and Packages)

The data flow: check, validate, sanitize, escape

Data crossing a trust boundary passes a fixed sequence: authorize the request, unslash, validate (reject what is not in an allowed form), sanitize (clean into a safe form), store, and escape for the exact output context as late as possible, so that values stored by an older version, another plugin or a database edit are still safe.

flowchart LR A["Input: $_GET, $_POST, REST params, remote data"] --> B{"Authorized? capability and nonce"} B -- no --> X["403: wp_die or WP_Error"] B -- yes --> C["wp_unslash"] C --> D{"Validate: allowed form?"} D -- no --> Y["400: reject, never repair silently"] D -- yes --> E["Sanitize: sanitize_text_field, absint, wp_kses"] E --> F[("Store: options, meta, prepared SQL")] F --> G["Escape late: esc_html, esc_attr, esc_url, wp_kses_post"] G --> H["Output: HTML, attribute, URL, JS"]

Validation answers yes or no and is the stronger option whenever the valid values are known; sanitizing transforms free text. A value sanitized on input is still escaped on output, because the output context decides which characters are dangerous.

Capabilities

Authorization in WordPress is capability-based. A role is only a named set of capabilities, so code checks capabilities, never role names: current_user_can( $capability, $object_id ) for the current user and user_can() for another one.

  • Primitive capabilities are stored in roles: edit_posts, edit_others_posts, publish_posts, manage_options, upload_files, edit_theme_options, unfiltered_html, and custom ones a plugin adds on activation.

  • Meta capabilities such as edit_post, delete_post, read_post, edit_user and edit_term take an object ID. map_meta_cap() translates them into the primitive capabilities that apply to that object. Checking edit_posts instead of current_user_can( 'edit_post', $post_id ) lets an Author change anybody’s books.

  • Custom post types get the same mapping, or their own capabilities, see Roles and Capabilities API.

is_admin() is often mistaken for authorization. It returns true when an admin screen is loading, including admin-ajax.php and admin-post.php, which every logged-in user can reach; it says nothing about the user. is_user_logged_in() is true for a Subscriber. Neither replaces a capability check. On the test site, map_meta_cap( 'edit_post', $user_id, 4 ) for a published book written by someone else returns edit_others_posts,edit_published_posts, so current_user_can( 'edit_post', 4 ) is true for the Editor maria.editor and false for the Author tom.author.

Nonces

A nonce is WordPress’s protection against cross-site request forgery (CSRF): a page on another site that makes a logged-in user’s browser submit a form, load a URL or call an endpoint. The browser sends the user’s cookies, so a capability check alone passes; the nonce proves that the request came from a page WordPress generated for that user.

Despite the name, a WordPress nonce is reusable: a hash of the action, user ID, session token and time tick, valid 24 hours by default (two 12-hour ticks; filter nonce_life). Another user’s or session’s nonce fails; logged-out visitors all share user ID 0. A nonce is not authorization: it shows that the user meant to send the request, not that they may, and nonces printed in pages can leak, so every handler also checks current_user_can(). Include the object ID in the action (lumen_feature_book_42) so a nonce for one book cannot be replayed against another.

Where Create Verify

Admin form, or a link that changes state

wp_nonce_field( $action ) (hidden _wpnonce and referer fields), wp_nonce_url( $url, $action )

check_admin_referer( $action ): dies with "The link you followed has expired." (403)

admin-ajax.php call

wp_create_nonce( $action ), passed with wp_localize_script() or wp_add_inline_script()

check_ajax_referer( $action ): reads _ajax_nonce or _wpnonce, dies with -1 (403)

REST API with cookies

wp_create_nonce( 'wp_rest' ); @wordpress/api-fetch sends it in the editor

Core checks the X-WP-Nonce header or _wpnonce parameter. Without it the request silently runs as user 0

Custom code calls wp_verify_nonce(), which returns 1 (first 12 hours), 2 (second 12 hours) or false. REST requests authenticated with application passwords or OAuth need no nonce, since there are no cookies for another site to ride on; see Authentication and Login API.

Validating and sanitizing input

WordPress adds slashes to $_GET, $_POST, $_COOKIE, $_SERVER and $_REQUEST at load time (wp_magic_quotes()), so superglobals are always read through wp_unslash() first, or O'Reilly is stored as O\'Reilly. The slashes are not a defense: they do nothing in numeric SQL or HTML contexts and disappear as soon as code unslashes, as it must. REST parameters and get_option() values are not slashed.

Function Use it for

absint(), intval()

IDs and counts; absint( '-42abc' ) returns 42, so also check that the ID exists

sanitize_text_field(), sanitize_textarea_field()

Plain text: strips tags, invalid UTF-8, extra whitespace and (single-line) line breaks

sanitize_key(), sanitize_title(), sanitize_file_name(), sanitize_email(), sanitize_hex_color()

Keys, slugs, file names (../../wp-config .php becomes wp-config-.php), addresses, colours

esc_url_raw(), sanitize_url()

A URL to store, redirect to or fetch; drops disallowed protocols such as javascript:

wp_kses(), wp_kses_post()

HTML that keeps an allow-listed set of tags

JSON Schema in register_rest_route(), register_post_meta(), register_setting()

REST arguments, registered meta and settings: one place that validates and sanitizes

Validate against an allow-list wherever possible (a strict in_array(), a preg_match() for an ISBN, get_post_type() equal to lp_book, is_email()) and reject invalid input with an error rather than silently "fixing" it.

Escaping output

Escape every dynamic value at the moment it is printed, with the function for that context. Late escaping makes each echo statement show that its value is safe, which reviewers and the coding-standards sniffs can check without tracing the data back.

Context Function

HTML element content

esc_html(); with translation esc_html__(), esc_html_e(); esc_textarea() in a <textarea>

HTML attribute value (always quoted)

esc_attr(); esc_attr__(), esc_attr_e()

URL in href, src, action

esc_url(): encodes & as &#038; and returns an empty string for javascript: and other disallowed protocols

Data for a script

esc_js() for a string in an inline onclick; otherwise wp_json_encode() with wp_add_inline_script() or wp_localize_script(), never concatenation

HTML that keeps allowed tags

wp_kses_post() (post-content rules) or wp_kses() with an explicit allow-list

<?php
$input = 'O\'Reilly <b onmouseover="alert(1)">bold</b> "quoted"';
foreach ( array( 'esc_html', 'esc_js', 'sanitize_text_field', 'wp_kses_post' ) as $fn ) {
	printf( "%-20s %s\n", $fn, $fn( $input ) );
}
printf( "%-20s %s\n", 'wp_kses (em only)', wp_kses( $input . ' <em>ok</em>', array( 'em' => array() ) ) );
printf( "%-20s %s\n", 'esc_url', esc_url( 'javascript:alert(1)' ) );
printf( "%-20s %s\n", 'esc_url', esc_url( 'https://lumenpress.test/books/?a=1&b=2' ) );
printf( "%-20s %s\n", 'wp_json_encode', wp_json_encode( array( 'title' => '</script><b>' ), JSON_HEX_TAG ) );
esc_html             O&#039;Reilly &lt;b onmouseover=&quot;alert(1)&quot;&gt;bold&lt;/b&gt; &quot;quoted&quot;
esc_js               O\'Reilly &lt;b onmouseover=&quot;alert(1)&quot;&gt;bold&lt;/b&gt; &quot;quoted&quot;
sanitize_text_field  O'Reilly bold "quoted"
wp_kses_post         O'Reilly <b>bold</b> "quoted"
wp_kses (em only)    O'Reilly bold "quoted" <em>ok</em>
esc_url
esc_url              https://lumenpress.test/books/?a=1&#038;b=2
wp_json_encode       {"title":"\u003C\/script\u003E\u003Cb\u003E"}

wp_kses_post() kept <b> but dropped the event handler. Users with unfiltered_html (Administrators and Editors, only Super Admins on Multisite) may save any HTML in post content, which still does not make it safe elsewhere. Translations are input too, hence esc_html__() rather than __().

Database queries

$wpdb runs whatever SQL it is given. Every query containing a variable goes through $wpdb->prepare(), which takes sprintf()-style placeholders, never quoted by hand: %s (string, quoted and escaped), %d and %f (integer and float), %i (identifier such as a table or column name, wrapped in backticks; added in WordPress 6.2 and detectable with $wpdb->has_cap( 'identifier_placeholders' )) and %% (a literal %).

LIKE wildcards belong in the argument, with the user’s part passed through $wpdb->esc_like() so that a typed _ or % matches literally:

<?php
global $wpdb;
$sql = $wpdb->prepare(
	'SELECT %i FROM %i WHERE meta_key = %s AND meta_value LIKE %s AND post_id = %d AND %f > 0',
	'meta_value',
	$wpdb->postmeta,
	'lp_isbn',
	'%' . $wpdb->esc_like( '978_1%' ) . '%',
	'5 OR 1=1',
	'3.5kg'
);
echo $wpdb->remove_placeholder_escape( $sql ), "\n"; // Printing only; pass $sql itself to $wpdb.
SELECT `meta_value` FROM `lp_postmeta` WHERE meta_key = 'lp_isbn' AND meta_value LIKE '%978\\_1\\%%' AND post_id = 5 AND 3.500000 > 0

%d turned 5 OR 1=1 into 5; % signs stay masked until the query runs, hence remove_placeholder_escape().

  • $wpdb->insert(), update() and delete() prepare their arrays themselves; query() and get_*() do not. IN lists need one placeholder per value (array_fill() and implode()).

  • User-chosen identifiers (a sort column) still need an allow-list: %i prevents injection, not unwanted columns.

  • WP_Query, get_posts() and the metadata APIs escape for you; see WP_Query and the Database API.

A vulnerable admin action and its fix

The Lumen Press catalogue team wants a "Catalogue tools" screen: search books by ISBN, mark one as featured with a short note, and return to a given page. The first version works and contains five classic vulnerabilities. It is attacked and then fixed below; it must never run anywhere but a disposable local site.

The vulnerable version

<?php
// lumen-catalog/includes/admin-tools.php: DELIBERATELY VULNERABLE, for a local test site only.
defined( 'ABSPATH' ) || exit;

add_action( 'admin_menu', function () {
	add_management_page( 'Catalogue tools', 'Catalogue tools', 'read', 'lumen-tools', 'lumen_catalog_tools_page' );
} );

function lumen_catalog_tools_page() {
	global $wpdb;
	$isbn = isset( $_GET['isbn'] ) ? wp_unslash( $_GET['isbn'] ) : '';
	echo '<div class="wrap"><h1>Catalogue tools</h1>';
	echo '<p>Featured note: ' . get_option( 'lumen_featured_note' ) . '</p>';
	if ( $isbn ) {
		echo '<p>Results for ' . $isbn . '</p>';
		$rows = $wpdb->get_results(
			"SELECT p.ID, p.post_title FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID
			 WHERE m.meta_key = 'lp_isbn' AND m.meta_value = '$isbn'"
		);
		foreach ( $rows as $row ) {
			echo '<p>' . $row->post_title . ' <a href="admin-post.php?action=lumen_feature_book&book=' . $row->ID . '">Feature</a></p>';
		}
	}
	echo '</div>';
}

add_action( 'admin_post_lumen_feature_book', function () {
	update_option( 'lumen_featured_book', $_REQUEST['book'] );
	update_option( 'lumen_featured_note', $_REQUEST['note'] ?? '' );
	wp_redirect( $_REQUEST['redirect_to'] ?? admin_url( 'tools.php?page=lumen-tools' ) );
	exit;
} );

The main plugin file loads it inside an is_admin() block (which is not an access check). The flaws:

  1. Missing capability. The menu requires only read, which every role has, and the admin_post_{$action} handler checks nothing: any logged-in user can call it.

  2. CSRF. No nonce, so any web page can make a logged-in Editor’s browser load the handler URL.

  3. SQL injection. $isbn is concatenated into the query. Unslashing is correct; concatenating is the bug.

  4. XSS. The search term (reflected) and the note (stored) are printed without escaping.

  5. Open redirect. wp_redirect() follows any redirect_to, including another site.

Attacking it on a local site

The site (Local Development Environments) has pretty permalinks, maria.editor (Editor), tom.author (Author) and two lp_book posts, IDs 4 and 5, with lp_isbn 978-1-23456-789-7 and 978-1-23456-790-3. A Bash helper logs in and keeps the cookies in a jar, as a browser would:

SITE=http://lumenpress.test:8080
login() { # user, password, cookie jar
  curl -s -o /dev/null -c "$3" -b 'wordpress_test_cookie=WP%20Cookie%20check' \
    --data-urlencode "log=$1" --data-urlencode "pwd=$2" -d testcookie=1 "$SITE/wp-login.php"
}
login tom.author "$TOM_PASSWORD" tom.jar
login maria.editor "$MARIA_PASSWORD" maria.jar

The Author, who should not see the screen at all, searches with a ' that closes the string and an OR that makes the condition true for every row:

curl -s -b tom.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
  --data-urlencode "isbn=x' OR '1'='1" | grep -o '<p>Results.*</p>'
<p>Results for x' OR '1'='1</p><p>Sample Page <a href="admin-post.php?action=lumen_feature_book&book=2">Feature</a></p><p>Privacy Policy <a href="admin-post.php?action=lumen_feature_book&book=3">Feature</a></p><p>The Lighthouse Keeper <a href="admin-post.php?action=lumen_feature_book&book=4">Feature</a></p><p>Salt and Ink <a href="admin-post.php?action=lumen_feature_book&book=5">Feature</a></p>

The query returned every post with any metadata, pages included; a UNION would read other tables. The same parameter reflects markup unchanged (isbn=<script>alert(1)</script> comes back inside the results paragraph as is), so a crafted link runs script in the browser of whoever opens it. The handler is reached by the Author without a nonce, stores a note with an event handler, and redirects off-site:

curl -s -b tom.jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -G "$SITE/wp-admin/admin-post.php" \
  -d action=lumen_feature_book -d book=5 --data-urlencode 'note=<img src=x onerror=alert(1)>' \
  --data-urlencode 'redirect_to=https://attacker.example/'
wp option get lumen_featured_note
302 https://attacker.example/
<img src=x onerror=alert(1)>

The Editor’s next visit prints <p>Featured note: <img src=x onerror=alert(1)></p>, running script with her session (or an Administrator’s, who can create users). Because the handler accepts GET, an <img> tag on any other site does the same through a logged-in Editor’s browser.

The fixed version

<?php
// lumen-catalog/includes/admin-tools.php
defined( 'ABSPATH' ) || exit;

const LUMEN_CATALOG_TOOLS_CAP = 'edit_others_posts';

add_action( 'admin_menu', function () {
	add_management_page( __( 'Catalogue tools', 'lumen-catalog' ), __( 'Catalogue tools', 'lumen-catalog' ), LUMEN_CATALOG_TOOLS_CAP, 'lumen-tools', 'lumen_catalog_tools_page' );
} );

function lumen_catalog_note_tags() { // Tags allowed in the featured note.
	return array( 'em' => array(), 'strong' => array(), 'a' => array( 'href' => true ) );
}

function lumen_catalog_tools_page() {
	global $wpdb;
	if ( ! current_user_can( LUMEN_CATALOG_TOOLS_CAP ) ) {
		wp_die( esc_html__( 'Sorry, you are not allowed to use the catalogue tools.', 'lumen-catalog' ), 403 );
	}
	// Read-only search: no nonce needed. Unslash, then sanitize.
	$isbn = isset( $_GET['isbn'] ) ? sanitize_text_field( wp_unslash( $_GET['isbn'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

	echo '<div class="wrap"><h1>' . esc_html__( 'Catalogue tools', 'lumen-catalog' ) . '</h1><p>'
		. esc_html__( 'Featured note:', 'lumen-catalog' ) . ' '
		. wp_kses( get_option( 'lumen_featured_note', '' ), lumen_catalog_note_tags() ) . '</p>';
	if ( '' !== $isbn ) {
		/* translators: %s: the ISBN searched for. */
		echo '<p>' . esc_html( sprintf( __( 'Results for %s', 'lumen-catalog' ), $isbn ) ) . '</p>';
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT p.ID, p.post_title FROM %i p JOIN %i m ON m.post_id = p.ID WHERE m.meta_key = %s AND m.meta_value = %s',
				$wpdb->posts,
				$wpdb->postmeta,
				'lp_isbn',
				$isbn
			)
		);
		foreach ( $rows as $row ) {
			$url = add_query_arg( array( 'action' => 'lumen_feature_book', 'book' => $row->ID ), admin_url( 'admin-post.php' ) );
			$url = wp_nonce_url( $url, 'lumen_feature_book_' . $row->ID );
			printf( '<p>%s <a href="%s">%s</a></p>', esc_html( $row->post_title ), esc_url( $url ), esc_html__( 'Feature', 'lumen-catalog' ) );
		}
	}
	echo '</div>';
}

add_action( 'admin_post_lumen_feature_book', function () {
	if ( ! current_user_can( LUMEN_CATALOG_TOOLS_CAP ) ) {
		wp_die( esc_html__( 'Sorry, you are not allowed to feature books.', 'lumen-catalog' ), 403 );
	}
	$book_id = isset( $_REQUEST['book'] ) ? absint( $_REQUEST['book'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification -- the nonce action includes the ID.
	check_admin_referer( 'lumen_feature_book_' . $book_id );
	if ( 'lp_book' !== get_post_type( $book_id ) || ! current_user_can( 'edit_post', $book_id ) ) {
		wp_die( esc_html__( 'Not a book you can edit.', 'lumen-catalog' ), 400 );
	}
	$note = isset( $_REQUEST['note'] ) ? wp_kses( wp_unslash( $_REQUEST['note'] ), lumen_catalog_note_tags() ) : '';
	update_option( 'lumen_featured_book', $book_id, false );
	update_option( 'lumen_featured_note', $note, false );

	$target = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url( 'tools.php?page=lumen-tools' );
	wp_safe_redirect( $target ); // Off-site targets fall back to admin_url().
	exit;
} );

What changed, flaw by flaw:

  • Capability: edit_others_posts for the menu and both callbacks (a menu capability only hides the entry), plus the meta capability edit_post on the specific book.

  • Nonce: per-book, verified by check_admin_referer() after the capability check; the read-only search needs none.

  • SQL: $wpdb->prepare() with %i for the tables and %s for the values.

  • XSS: the search term is sanitized and then escaped with esc_html(); the note passes wp_kses() with a three-tag allow-list when stored and when printed; titles and URLs are escaped.

  • Redirect: wp_safe_redirect() accepts only the site’s host (plus allowed_redirect_hosts) and otherwise goes to admin_url() (filter wp_safe_redirect_fallback). The options are stored with autoload off.

The same requests against the fixed version

# The Author can no longer open the screen or call the handler.
curl -s -b tom.jar -o /dev/null -w '%{http_code}\n' "$SITE/wp-admin/tools.php?page=lumen-tools"
curl -s -b tom.jar -G "$SITE/wp-admin/admin-post.php" -d action=lumen_feature_book -d book=5 | grep -o 'Sorry[^<]*'
# The injection is now a string that matches nothing.
curl -s -b maria.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
  --data-urlencode "isbn=x' OR '1'='1" | grep -o '<p>Results.*</p>'
# A forged request (no nonce) from the Editor's browser is refused.
curl -s -b maria.jar -G "$SITE/wp-admin/admin-post.php" -d action=lumen_feature_book -d book=5 | grep -o 'The link[^<]*'
# With the nonce from the screen: the note is filtered and the off-site redirect is refused.
NONCE=$(curl -s -b maria.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
  --data-urlencode "isbn=978-1-23456-790-3" | grep -o 'book=5&#038;_wpnonce=[0-9a-f]*' | sed 's/.*_wpnonce=//')
curl -s -b maria.jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -G "$SITE/wp-admin/admin-post.php" \
  -d action=lumen_feature_book -d book=5 -d _wpnonce="$NONCE" \
  --data-urlencode 'note=<strong>New</strong> <img src=x onerror=alert(1)>' \
  --data-urlencode 'redirect_to=https://attacker.example/'
wp option get lumen_featured_note
# The nonce for book 5 does not work for book 4.
curl -s -b maria.jar -o /dev/null -w '%{http_code}\n' -G "$SITE/wp-admin/admin-post.php" \
  -d action=lumen_feature_book -d book=4 -d _wpnonce="$NONCE"
403
Sorry, you are not allowed to feature books.
<p>Results for x&#039; OR &#039;1&#039;=&#039;1</p>
The link you followed has expired.
302 http://lumenpress.test:8080/wp-admin/
<strong>New</strong>
403

What the coding standards report

The WordPress Coding Standards include sniffs that find most of these flaws statically (Composer setup in Static Analysis and Refactoring):

composer require --dev wp-coding-standards/wpcs:3.4.1
vendor/bin/phpcs --standard=WordPress --report=emacs \
  --sniffs=WordPress.Security.EscapeOutput,WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput,WordPress.Security.SafeRedirect,WordPress.DB.PreparedSQL,WordPress.DB.PreparedSQLPlaceholders \
  includes/

For the vulnerable file they report 18 findings, among them (shortened):

admin-tools.php:11:50: error - Detected usage of a non-sanitized input variable: $_GET['isbn']
admin-tools.php:15:34: error - All output should be run through an escaping function (...), found '$isbn'.
admin-tools.php:18:1: error - Use placeholders and $wpdb->prepare(); found interpolated variable $isbn at ...
admin-tools.php:29:43: warning - Processing form data without nonce verification.
admin-tools.php:30:5: warning - wp_redirect() found. Using wp_safe_redirect(), along with the "allowed_redirect_hosts" filter ...

The fixed files on this page report nothing. The sniffs cannot see a missing capability check, and a phpcs:ignore comment silences them, so every ignore carries a reason and is reviewed like code. The Plugin Check plugin runs the same sniffs before a directory submission; see Publishing to the Plugin Directory.

AJAX and REST endpoints

admin-ajax.php. Handlers are registered on wp_ajax_{$action} (logged-in users of any role) and wp_ajax_nopriv_{$action} (visitors). Some older books post from jQuery with no nonce to a handler that checks neither a nonce nor a capability. The handler below does both, and the nonce travels to the script with its data:

<?php
// lumen-catalog/includes/ajax.php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_enqueue_scripts', function ( $hook_suffix ) {
	if ( 'tools_page_lumen-tools' !== $hook_suffix ) {
		return;
	}
	wp_enqueue_script( 'lumen-tools', plugins_url( 'assets/tools.js', __DIR__ ), array( 'jquery' ), '0.1.0', true );
	wp_localize_script( 'lumen-tools', 'lumenTools', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'lumen_book_count' ) ) );
} );

add_action( 'wp_ajax_lumen_book_count', function () {
	check_ajax_referer( 'lumen_book_count' ); // Dies with -1 and status 403 on failure.
	if ( ! current_user_can( 'edit_posts' ) ) {
		wp_send_json_error( null, 403 );
	}
	wp_send_json_success( array( 'count' => (int) wp_count_posts( 'lp_book' )->publish ) );
} );
// lumen-catalog/assets/tools.js
( function ( $ ) {
	$.post( lumenTools.ajaxUrl, { action: 'lumen_book_count', _ajax_nonce: lumenTools.nonce } ).done( function ( response ) {
		$( '.wrap h1' ).append( document.createTextNode( ' (' + response.data.count + ')' ) );
	} );
} )( jQuery );

The wrapper receives jQuery as $ (WordPress loads jQuery in noConflict mode, so a bare $ is undefined) and inserts text, never HTML. Without the nonce the handler answers -1 (403), with it {"success":true,"data":{"count":2}}, and logged out 0 (400, no nopriv handler). New code prefers REST; see JavaScript, AJAX and Heartbeat.

REST API. Every route needs a permission_callback: leaving it out triggers a _doing_it_wrong notice since WordPress 5.5, and __return_true is only for routes meant to be public. Arguments are described with JSON Schema so that core validates them before the callback runs:

<?php
// lumen-catalog/includes/rest.php
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', function () {
	register_rest_route( 'lumen/v1', '/featured', array(
		'methods'             => WP_REST_Server::EDITABLE,
		'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
		'args'                => array(
			'book' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1 ),
		),
		'callback'            => function ( WP_REST_Request $request ) {
			$book_id = $request['book'];
			if ( 'lp_book' !== get_post_type( $book_id ) ) {
				return new WP_Error( 'lumen_not_a_book', __( 'Not a book.', 'lumen-catalog' ), array( 'status' => 400 ) );
			}
			update_option( 'lumen_featured_book', $book_id, false );
			return array( 'featured' => $book_id );
		},
	) );
} );
curl -s -b maria.jar -X POST "$SITE/wp-json/lumen/v1/featured" -d book=4            # cookies, no nonce
RN=$(curl -s -b maria.jar "$SITE/wp-admin/admin-ajax.php?action=rest-nonce")        # the wp_rest nonce
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -X POST "$SITE/wp-json/lumen/v1/featured" -d book=4
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -X POST "$SITE/wp-json/lumen/v1/featured" -d book=abc
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":401}}
{"featured":4}
{"code":"rest_invalid_param","message":"Invalid parameter(s): book","data":{"status":400,"params":{"book":"book is not of type integer."},"details":{"book":{"code":"rest_invalid_type","message":"book is not of type integer.","data":{"param":"book"}}}}}

The first request had valid login cookies but no wp_rest nonce, so it ran as user 0 and got 401; the Author with his own nonce gets 403. A pitfall: a custom sanitize_callback (such as absint) replaces the default rest_parse_request_arg(), which is what validates against the schema; with it, book=abc silently became 0 and reached the callback. See REST API: Extending It.

Theme options and get_theme_mod

Some older books register a Customizer setting without a sanitize_callback and echo get_theme_mod() straight into the template. Whoever can save the setting, or any code that writes the theme mod, then controls raw markup on every page. Sanitize when the setting is saved and escape when it is printed:

<?php
// lumen-classic/functions.php
add_action( 'customize_register', function ( WP_Customize_Manager $wp_customize ) {
	$wp_customize->add_setting( 'lumen_classic_footer_note', array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ) );
	$wp_customize->add_control( 'lumen_classic_footer_note', array( 'label' => __( 'Footer note', 'lumen-classic' ), 'section' => 'title_tagline' ) );
} );
<?php // lumen-classic/footer.php (excerpt) ?>
<p class="footer-note"><?php echo esc_html( get_theme_mod( 'lumen_classic_footer_note', '' ) ); ?></p>

URLs pair esc_url_raw with esc_url(), colours sanitize_hex_color with esc_attr(); see Customizer API.

File uploads

An upload handler decides what ends up inside the web root. Check upload_files and a nonce as for any state change, then let WordPress move the file with wp_handle_upload() (form uploads), wp_handle_sideload() (files already on the server) or media_handle_upload() (which also creates an attachment). They run wp_check_filetype_and_ext(), which compares the extension with the real content, and sanitize and de-duplicate the file name. Pass a restricted mimes list; otherwise the site-wide get_allowed_mime_types() (filter upload_mimes) applies, which must never gain php, svg or html. Never trust the browser’s type field or write uploads to a path built from input.

<?php
// lumen-catalog/includes/cover-upload.php: form posts lumen_cover with wp_nonce_field( 'lumen_upload_cover' ).
defined( 'ABSPATH' ) || exit;
add_action( 'admin_post_lumen_upload_cover', function () {
	if ( ! current_user_can( 'upload_files' ) ) {
		wp_die( esc_html__( 'Sorry, you are not allowed to upload files.', 'lumen-catalog' ), 403 );
	}
	check_admin_referer( 'lumen_upload_cover' );
	if ( empty( $_FILES['lumen_cover'] ) ) {
		wp_die( esc_html__( 'No file was sent.', 'lumen-catalog' ), 400 );
	}
	$result = wp_handle_upload( // Loaded by admin-post.php; elsewhere require wp-admin/includes/file.php.
		$_FILES['lumen_cover'], // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- checked by wp_handle_upload().
		array(
			'test_form' => false, // The form does not post action=wp_handle_upload.
			'mimes'     => array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' ),
		)
	);
	if ( isset( $result['error'] ) ) {
		wp_die( esc_html( $result['error'] ), 400 );
	}
	wp_safe_redirect( admin_url( 'tools.php?page=lumen-tools' ) );
	exit;
} );

The same checks in a wp eval-file script, through wp_handle_sideload() (same code path, for files not just posted):

<?php
$png   = base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' );
$files = array( 'cover.png' => $png, 'shell.jpg' => "<?php echo 'not an image';", 'misnamed.jpg' => $png );
$mimes = array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png' );
foreach ( $files as $name => $bytes ) {
	$path = get_temp_dir() . $name;
	file_put_contents( $path, $bytes );
	$check = wp_check_filetype_and_ext( $path, $name, $mimes );
	printf( "%-13s ext=%-5s type=%-10s proper_filename=%s\n", $name, var_export( $check['ext'], true ), var_export( $check['type'], true ), var_export( $check['proper_filename'], true ) );
	$file   = array( 'name' => $name, 'type' => 'image/jpeg', 'tmp_name' => $path, 'error' => 0, 'size' => filesize( $path ) );
	$result = wp_handle_sideload( $file, array( 'test_form' => false, 'mimes' => $mimes ) );
	echo '  ', isset( $result['error'] ) ? 'error: ' . $result['error'] : 'stored as ' . basename( $result['file'] ) . ' (' . $result['type'] . ')', "\n";
}
cover.png     ext='png' type='image/png' proper_filename=false
  stored as cover.png (image/png)
shell.jpg     ext=false type=false      proper_filename=false
  error: Sorry, you are not allowed to upload this file type.
misnamed.jpg  ext='png' type='image/png' proper_filename='misnamed.png'
  stored as misnamed.png (image/png)

The PHP file named .jpg was refused although the client claimed image/jpeg, and the PNG named .jpg was stored under its real extension. Posted to the handler above, the PNG is stored (302 back to the tools screen) and the PHP file gets "Sorry, you are not allowed to upload this file type." (400). The web server should also refuse PHP in uploads/ (Web Servers: Apache and nginx).

SSRF and the HTTP API

Server-side request forgery (SSRF) happens when the server fetches a URL that a user controls (an importer, a webhook target, a "fetch the cover from this address" field) and so reaches the database container, an internal admin panel or the cloud metadata service at 169.254.169.254, which hands out credentials. wp_remote_get() fetches anything. wp_safe_remote_get(), wp_safe_remote_post(), wp_safe_remote_head() and wp_safe_remote_request() set reject_unsafe_urls, so wp_http_validate_url() checks the URL and every redirect target: http or https only, no credentials in the URL, no host resolving to a loopback, private, link-local (cloud metadata), carrier-grade NAT, documentation, multicast or reserved address (unless it is the site’s own host or http_request_host_is_external allows it), and port 80, 443 or 8080 only (filter http_allowed_safe_ports; a public host on port 8443 is refused too).

<?php
$urls = array(
	'http://web/',                              // Another container on the private network.
	'http://169.254.169.254/latest/meta-data/', // Cloud instance metadata.
	'https://api.wordpress.org/core/version-check/1.7/',
);
foreach ( $urls as $url ) {
	foreach ( array( 'wp_remote_get', 'wp_safe_remote_get' ) as $fn ) {
		$response = $fn( $url, array( 'timeout' => 3, 'redirection' => 0 ) );
		printf( "%-18s %-50s %s\n", $fn, $url, is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : wp_remote_retrieve_response_code( $response ) );
	}
}
wp_remote_get      http://web/                                        301
wp_safe_remote_get http://web/                                        WP_Error: A valid URL was not provided.
wp_remote_get      http://169.254.169.254/latest/meta-data/           WP_Error: cURL error 28: Connection timed out after 3013 milliseconds
wp_safe_remote_get http://169.254.169.254/latest/meta-data/           WP_Error: A valid URL was not provided.
wp_remote_get      https://api.wordpress.org/core/version-check/1.7/  200
wp_safe_remote_get https://api.wordpress.org/core/version-check/1.7/  200

web is the test stack’s web server container: the plain function reached it (the 301 is WordPress’s canonical redirect), the safe one refused before connecting. On a cloud VM the metadata call would have returned data. Use the safe variants for every URL that is not a constant, and treat responses as untrusted input (validate, escape, short timeout, limit_response_size). See HTTP and Filesystem APIs.

Safe redirects

wp_safe_redirect() passes the target through wp_validate_redirect(), which keeps relative paths and the site’s own host and replaces anything else with the fallback: on the test site https://attacker.example/ and //attacker.example/ became the fallback, /wp-admin/edit.php?post_type=lp_book passed. Follow every redirect with exit;, and allow legitimate external targets with the allowed_redirect_hosts filter, never by going back to wp_redirect().

Other pitfalls

  • Direct file access: every PHP file starts with defined( 'ABSPATH' ) || exit;.

  • Object injection and dynamic code: no unserialize() of untrusted data (use JSON), no eval(), no extract() on request data, no include of a path built from input.

  • Disclosure and secrets: meta and fields with show_in_rest are public; WP_DEBUG_DISPLAY is false in production; API keys live in the environment or wp-config.php (wp-config.php Reference), not in autoloaded options.

  • Comparisons and roles: hash_equals() for tokens, strict comparisons everywhere; add_role() runs once on activation, not on every init.

Where older books are outdated

Older example or claim Current practice (WordPress 7.1)

An admin-ajax.php call from jQuery with no nonce, and a handler without a capability check

check_ajax_referer() plus current_user_can(); jQuery wrapped for noConflict mode; the REST API with a permission_callback for new code

A Customizer setting without sanitize_callback and an unescaped get_theme_mod() in a template

A sanitize_callback on the setting and esc_html(), esc_attr() or esc_url() on output

is_admin() as a check that the user is an administrator

It only tells that an admin screen is loading; check current_user_can()

esc_sql() or addslashes() to make a query safe; table names concatenated

$wpdb->prepare(), with %i for identifiers since 6.2

wp_redirect() to a URL from the request

wp_safe_redirect() and allowed_redirect_hosts

Further corrections are collected in Where Older Books and Tutorials Are Outdated.