WordPress security audit
A defensive checklist-driven review for WP plugin and theme PHP. Goal:
catch the boring, repeatable mistakes that ship to production because no
one ran through the basics. This is not a substitute for a real
security review of cryptography, business logic, or third-party deps.
When to use this skill
Trigger this skill when ANY of the following is true:
- The user asks for a security review, audit, or "is this safe".
- The diff or file under discussion contains:
$_GET, $_POST,
$_REQUEST, $_COOKIE, $_FILES, $_SERVER, wp_unslash,
wp_verify_nonce, check_admin_referer, current_user_can,
add_action( 'wp_ajax, add_action( 'admin_post,
register_rest_route, $wpdb->, update_option, update_user_meta,
wp_redirect, wp_safe_redirect, file_get_contents, move_uploaded_file.
- The user is preparing a plugin for release or wp.org submission.
- The user is reviewing a contributor's PR.
How to run the audit
Work through the Critical checks below in order. For each finding:
- State the file and line.
- Name the issue using its conventional WP terminology
(e.g. "missing nonce", "unescaped output", "broken access control").
- Show the offending code (1–3 lines).
- Show the fix.
- Mark severity: HIGH (exploitable now), MEDIUM (exploitable under
conditions), LOW (hardening / best practice).
Do NOT silently rewrite the file. Produce a report first; only edit if the
user asks you to apply fixes.
Critical checks
1. Nonce verification on state-changing requests
Any handler that writes (saves option, updates meta, deletes a post,
sends an email, mutates anything) must verify a nonce.
- Forms:
wp_nonce_field( 'action_name', '_wpnonce' ) →
check_admin_referer( 'action_name' ) in handler.
- AJAX:
wp_create_nonce( 'action_name' ) → check_ajax_referer( 'action_name', 'nonce' ).
- REST: rely on cookie auth + the built-in
_wpnonce (wp-api nonce) for
logged-in routes; for permission_callback use a real capability check.
Common mistake: verifying the nonce inside an if whose else branch
still does the write. The nonce check must short-circuit.
2. Capability checks (authorization)
Authentication ≠ authorization. A logged-in subscriber is still a user.
- Admin actions:
current_user_can( 'manage_options' ) or a more
specific capability (edit_posts, edit_post with object ID,
manage_woocommerce etc.).
- Object-level actions MUST pass the object ID:
current_user_can( 'edit_post', $post_id ) — the ID-less form is wrong.
- REST
permission_callback must NEVER be __return_true for
state-changing routes. Returning true unconditionally is the #1 plugin
vulnerability pattern on wp.org.
3. Input: unslash → sanitize → validate
WordPress magically slashes superglobals. The pipeline is fixed:
$raw = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';
$email = sanitize_email( $raw );
if ( ! is_email( $email ) ) { /* reject */ }
- Missing
wp_unslash before sanitization → escaped quotes leak through.
- Wrong sanitizer for the data type. Map by intent:
- text field:
sanitize_text_field
- textarea:
sanitize_textarea_field
- email:
sanitize_email
- URL:
esc_url_raw (storage) / esc_url (output)
- key/slug:
sanitize_key, sanitize_title
- integer:
absint or (int) with range check
- HTML allowed:
wp_kses_post or wp_kses with explicit allowlist
- file path:
sanitize_file_name + realpath containment check
- Never trust
$_SERVER['HTTP_*'] headers without sanitizing; they're
attacker-controlled.
4. Output escaping (XSS)
Escape at the point of output, in the right context:
- HTML body:
esc_html( $x )
- HTML attribute:
esc_attr( $x )
- URL in
href/src: esc_url( $x )
- Inside
<script> JSON: wp_json_encode( $x ), never raw concatenation
- Translated strings with placeholders: escape the template AND
the substituted value separately.
esc_html__() only escapes
the static template; printf( esc_html__( '%s', 'td' ), $name ) is
XSS if $name contains markup. Correct form:
printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) ).
- Already-HTML content (post content):
wp_kses_post( $x )
echo $foo; where $foo came from input or DB without escaping → XSS.
This is the most common finding in plugin audits.
5. SQL: always prepare
// WRONG
$wpdb->get_results( "SELECT * FROM x WHERE id = $id" );
// RIGHT
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );
%d integers, %f floats, %s strings. Table/column names CANNOT be
parameterized — validate against an allowlist before interpolating.
LIKE needs $wpdb->esc_like() BEFORE prepare():
$like = '%' . $wpdb->esc_like( $term ) . '%';
- Prefer WP_Query / get_posts / get_users over raw SQL where possible.
6. AJAX endpoints
Two hooks, two meanings — confuse them and you ship a vulnerability:
wp_ajax_{action} — fires only for logged-in users.
wp_ajax_nopriv_{action} — fires for logged-out users.
Rules:
- Register
nopriv ONLY if the feature is genuinely meant for guests
(e.g. public search, login form). Never copy-paste both registrations
"to be safe".
- Both handlers still need
check_ajax_referer().
- The
nopriv handler must NOT perform actions that only logged-in users
should do (saving prefs, accessing other users' data, etc.).
- End with
wp_send_json_success / wp_send_json_error, not echo + die.
7. admin-post and form handlers
admin_post_{action} and admin_post_nopriv_{action} follow the same
rules as AJAX. Plus: redirect with wp_safe_redirect() + exit;. Never
redirect with wp_redirect( $_GET['redirect_to'] ) without validating
against an allowlist — that's an open-redirect.
8. REST API routes
register_rest_route( 'myplugin/v1', '/save', [
'methods' => 'POST',
'callback' => 'myplugin_save',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => [
'id' => [
'required' => true,
'validate_callback' => 'is_numeric',
'sanitize_callback' => 'absint',
],
],
] );
Findings to flag:
permission_callback missing, or __return_true on a non-public route.
- No
args schema — input is unsanitized.
- Returning raw DB rows including sensitive columns (
user_pass,
user_activation_key, private meta).
9. File operations
- Uploads: validate MIME via
wp_check_filetype_and_ext(), store via
wp_handle_upload(), never trust the client-provided extension or MIME.
- Path joins with user input: after building,
realpath() and check the
result starts with the intended base dir. Otherwise: path traversal.
- Never
include / require a path containing user input.
10. Secrets and information disclosure
- No API keys or DB credentials in the plugin source. Use options or
constants in
wp-config.php.
WP_DEBUG_DISPLAY should be off in prod; flag any var_dump,
print_r, error_log( $sensitive ) left in handlers.
- Don't leak stack traces, full SQL, or user enumeration via error
messages ("user not found" vs "wrong password" — pick one).
11. Redirects
- Use
wp_safe_redirect() for any URL that may be influenced by input.
- Always
exit; after a redirect — execution continues otherwise.
12. Cron and background jobs
wp_schedule_event callbacks run with no current user. If the job
performs privileged work, do not trust any "stored intent" without
re-validating; treat persisted user input as untrusted.
What this skill does NOT cover
- Cryptographic correctness (key derivation, signing schemes).
- Business-logic flaws (race conditions, IDOR beyond capability checks).
- Third-party library CVEs — run
composer audit separately.
- Frontend JS XSS — different skill.
- Server / hosting hardening (file perms, disable_functions, etc.).
- Object injection, SSRF, CSRF on GET, mass assignment, file include,
mail/zip injection, timing comparison, TOCTOU races — these are
covered by
wp-security-deep. Run it after this one.
- Hardcoded credentials, weak randomness for tokens, password
storage, cookie flags, secrets in logs — covered by
wp-security-secrets. Run it whenever auth or third-party
integrations are in scope.
State this scope explicitly in the report. If the reviewed code
clearly falls into one of the deeper categories above, recommend the
appropriate skill in the report's footer.
Report format
# Security audit: <plugin name>
Scope: <files reviewed>
Date: <YYYY-MM-DD>
## HIGH
1. <file>:<line> — <issue>
<code>
Fix: <code>
## MEDIUM
...
## LOW / Hardening
...
## Out of scope
- <thing not checked>
References
1---2name: wp-security-audit3description: Audits WordPress plugin or theme PHP code for common security mistakes including missing nonce checks, capability checks, input sanitization, output escaping, SQL preparation, AJAX exposure, file traversal, and unsafe redirects.4---56# WordPress security audit78A defensive checklist-driven review for WP plugin and theme PHP. Goal:9catch the boring, repeatable mistakes that ship to production because no10one ran through the basics. This is **not** a substitute for a real11security review of cryptography, business logic, or third-party deps.1213## When to use this skill1415Trigger this skill when ANY of the following is true:1617- The user asks for a security review, audit, or "is this safe".18- The diff or file under discussion contains: `$_GET`, `$_POST`,19 `$_REQUEST`, `$_COOKIE`, `$_FILES`, `$_SERVER`, `wp_unslash`,20 `wp_verify_nonce`, `check_admin_referer`, `current_user_can`,21 `add_action( 'wp_ajax`, `add_action( 'admin_post`,22 `register_rest_route`, `$wpdb->`, `update_option`, `update_user_meta`,23 `wp_redirect`, `wp_safe_redirect`, `file_get_contents`, `move_uploaded_file`.24- The user is preparing a plugin for release or wp.org submission.25- The user is reviewing a contributor's PR.2627## How to run the audit2829Work through the **Critical checks** below in order. For each finding:30311. State the file and line.322. Name the issue using its conventional WP terminology33 (e.g. "missing nonce", "unescaped output", "broken access control").343. Show the offending code (1–3 lines).354. Show the fix.365. Mark severity: **HIGH** (exploitable now), **MEDIUM** (exploitable under37 conditions), **LOW** (hardening / best practice).3839Do NOT silently rewrite the file. Produce a report first; only edit if the40user asks you to apply fixes.4142## Critical checks4344### 1. Nonce verification on state-changing requests4546Any handler that *writes* (saves option, updates meta, deletes a post,47sends an email, mutates anything) must verify a nonce.4849- Forms: `wp_nonce_field( 'action_name', '_wpnonce' )` →50 `check_admin_referer( 'action_name' )` in handler.51- AJAX: `wp_create_nonce( 'action_name' )` → `check_ajax_referer( 'action_name', 'nonce' )`.52- REST: rely on cookie auth + the built-in `_wpnonce` (`wp-api` nonce) for53 logged-in routes; for `permission_callback` use a real capability check.5455**Common mistake:** verifying the nonce inside an `if` whose `else` branch56still does the write. The nonce check must short-circuit.5758### 2. Capability checks (authorization)5960Authentication ≠ authorization. A logged-in subscriber is still a user.6162- Admin actions: `current_user_can( 'manage_options' )` or a more63 specific capability (`edit_posts`, `edit_post` with object ID,64 `manage_woocommerce` etc.).65- Object-level actions MUST pass the object ID:66 `current_user_can( 'edit_post', $post_id )` — the ID-less form is wrong.67- REST `permission_callback` must NEVER be `__return_true` for68 state-changing routes. Returning `true` unconditionally is the #1 plugin69 vulnerability pattern on wp.org.7071### 3. Input: unslash → sanitize → validate7273WordPress magically slashes superglobals. The pipeline is fixed:7475```php76$raw = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';77$email = sanitize_email( $raw );78if ( ! is_email( $email ) ) { /* reject */ }79```8081- Missing `wp_unslash` before sanitization → escaped quotes leak through.82- Wrong sanitizer for the data type. Map by intent:83 - text field: `sanitize_text_field`84 - textarea: `sanitize_textarea_field`85 - email: `sanitize_email`86 - URL: `esc_url_raw` (storage) / `esc_url` (output)87 - key/slug: `sanitize_key`, `sanitize_title`88 - integer: `absint` or `(int)` with range check89 - HTML allowed: `wp_kses_post` or `wp_kses` with explicit allowlist90 - file path: `sanitize_file_name` + `realpath` containment check91- Never trust `$_SERVER['HTTP_*']` headers without sanitizing; they're92 attacker-controlled.9394### 4. Output escaping (XSS)9596Escape **at the point of output**, in the right context:9798- HTML body: `esc_html( $x )`99- HTML attribute: `esc_attr( $x )`100- URL in `href`/`src`: `esc_url( $x )`101- Inside `<script>` JSON: `wp_json_encode( $x )`, never raw concatenation102- Translated strings with placeholders: escape the **template** AND103 the **substituted value** separately. `esc_html__()` only escapes104 the static template; `printf( esc_html__( '%s', 'td' ), $name )` is105 XSS if `$name` contains markup. Correct form:106 `printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) )`.107- Already-HTML content (post content): `wp_kses_post( $x )`108109`echo $foo;` where `$foo` came from input or DB without escaping → XSS.110This is the most common finding in plugin audits.111112### 5. SQL: always prepare113114```php115// WRONG116$wpdb->get_results( "SELECT * FROM x WHERE id = $id" );117118// RIGHT119$wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );120```121122- `%d` integers, `%f` floats, `%s` strings. Table/column names CANNOT be123 parameterized — validate against an allowlist before interpolating.124- `LIKE` needs `$wpdb->esc_like()` BEFORE `prepare()`:125 `$like = '%' . $wpdb->esc_like( $term ) . '%';`126- Prefer WP_Query / get_posts / get_users over raw SQL where possible.127128### 6. AJAX endpoints129130Two hooks, two meanings — confuse them and you ship a vulnerability:131132- `wp_ajax_{action}` — fires only for **logged-in** users.133- `wp_ajax_nopriv_{action}` — fires for **logged-out** users.134135Rules:136- Register `nopriv` ONLY if the feature is genuinely meant for guests137 (e.g. public search, login form). Never copy-paste both registrations138 "to be safe".139- Both handlers still need `check_ajax_referer()`.140- The `nopriv` handler must NOT perform actions that only logged-in users141 should do (saving prefs, accessing other users' data, etc.).142- End with `wp_send_json_success` / `wp_send_json_error`, not `echo` + `die`.143144### 7. admin-post and form handlers145146`admin_post_{action}` and `admin_post_nopriv_{action}` follow the same147rules as AJAX. Plus: redirect with `wp_safe_redirect()` + `exit;`. Never148redirect with `wp_redirect( $_GET['redirect_to'] )` without validating149against an allowlist — that's an open-redirect.150151### 8. REST API routes152153```php154register_rest_route( 'myplugin/v1', '/save', [155 'methods' => 'POST',156 'callback' => 'myplugin_save',157 'permission_callback' => function () {158 return current_user_can( 'manage_options' );159 },160 'args' => [161 'id' => [162 'required' => true,163 'validate_callback' => 'is_numeric',164 'sanitize_callback' => 'absint',165 ],166 ],167] );168```169170Findings to flag:171- `permission_callback` missing, or `__return_true` on a non-public route.172- No `args` schema — input is unsanitized.173- Returning raw DB rows including sensitive columns (`user_pass`,174 `user_activation_key`, private meta).175176### 9. File operations177178- Uploads: validate MIME via `wp_check_filetype_and_ext()`, store via179 `wp_handle_upload()`, never trust the client-provided extension or MIME.180- Path joins with user input: after building, `realpath()` and check the181 result starts with the intended base dir. Otherwise: path traversal.182- Never `include` / `require` a path containing user input.183184### 10. Secrets and information disclosure185186- No API keys or DB credentials in the plugin source. Use options or187 constants in `wp-config.php`.188- `WP_DEBUG_DISPLAY` should be off in prod; flag any `var_dump`,189 `print_r`, `error_log( $sensitive )` left in handlers.190- Don't leak stack traces, full SQL, or user enumeration via error191 messages ("user not found" vs "wrong password" — pick one).192193### 11. Redirects194195- Use `wp_safe_redirect()` for any URL that may be influenced by input.196- Always `exit;` after a redirect — execution continues otherwise.197198### 12. Cron and background jobs199200- `wp_schedule_event` callbacks run with no current user. If the job201 performs privileged work, do not trust any "stored intent" without202 re-validating; treat persisted user input as untrusted.203204## What this skill does NOT cover205206- Cryptographic correctness (key derivation, signing schemes).207- Business-logic flaws (race conditions, IDOR beyond capability checks).208- Third-party library CVEs — run `composer audit` separately.209- Frontend JS XSS — different skill.210- Server / hosting hardening (file perms, disable_functions, etc.).211- Object injection, SSRF, CSRF on GET, mass assignment, file include,212 mail/zip injection, timing comparison, TOCTOU races — these are213 covered by **`wp-security-deep`**. Run it after this one.214- Hardcoded credentials, weak randomness for tokens, password215 storage, cookie flags, secrets in logs — covered by216 **`wp-security-secrets`**. Run it whenever auth or third-party217 integrations are in scope.218219State this scope explicitly in the report. If the reviewed code220clearly falls into one of the deeper categories above, recommend the221appropriate skill in the report's footer.222223## Report format224225```226# Security audit: <plugin name>227Scope: <files reviewed>228Date: <YYYY-MM-DD>229230## HIGH2311. <file>:<line> — <issue>232 <code>233 Fix: <code>234235## MEDIUM236...237238## LOW / Hardening239...240241## Out of scope242- <thing not checked>243```244245## References246247- Detailed examples of each finding type, before/after: `reference.md`248- Real-world snippets with the fix applied: `examples/`249- WordPress core: [Plugin Security Handbook](https://developer.wordpress.org/plugins/security/)250- Capability map: [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities/)