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).
- Mark the evidence status separately from severity:
- Reproduced — a controlled test reached the sink or observed the effect;
- Source-proven — the complete deterministic path and prerequisites are
visible in the inspected code/core contracts;
- Environment-dependent hypothesis — the effect needs a deployment
property or integration that was not available to test.
Do not present an environment-dependent hypothesis as a confirmed finding. Put
it under limitations or required validation, name the missing environment, and
do not promote it into a reusable skill rule until it is reproduced or the
relevant runtime contract is verified. Severity describes impact and
exploitability; it does not compensate for weak evidence.
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 cookie-authenticated browser handler that writes (saves an option,
updates meta, deletes a post, sends an email, or mutates anything) must
verify request intent with 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.
A nonce is not authentication or authorization. Signed webhooks,
Application Password/OAuth clients, cron, and WP-CLI use their own trust
boundary instead of a WordPress nonce. Guest nonces do not identify a guest;
public writes also need abuse controls such as throttling, replay protection,
or CAPTCHA where appropriate. A cacheable, read-only public endpoint does not
automatically need a nonce.
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 enforce the route's actual access policy.
__return_true is dangerous on privileged writes, but can be intentional
for genuinely public endpoints. Signed webhook routes should verify the
signature before mutation, preferably in permission_callback.
- In multisite,
is_user_member_of_blog() is a membership predicate, not a
capability check. WordPress 7.1 adds the is_user_member_of_blog filter, but
it runs only after both the user and an active site have resolved. Returning
true can affect core REST user/application-password checks and admin UI
visibility globally; do not use the filter to manufacture authorization.
Keep an object-appropriate current_user_can() check at the protected sink.
3. Input: unslash → normalize when needed → validate
WordPress slashes superglobals. First recover the domain value, then choose
lossy normalization only when the field's meaning permits it, and always
validate the semantic contract:
$raw = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';
$email = sanitize_email( $raw );
if ( ! is_email( $email ) ) { /* reject */ }
- Missing
wp_unslash before normalization can leak transport slashes into
stored data. Do not unslash values that did not come from a slashed boundary.
- Choose the normalizer by field meaning: text, textarea, email, URL, key,
integer, allowed HTML, and filesystem path need different contracts. See the
extended
reference.md map.
- Never trust
$_SERVER['HTTP_*'] headers without sanitizing; they're
attacker-controlled.
Sanitization is not a ritual and is often lossy. Migration, import/export,
database repair, code-editor, and opaque meta-value tools may need to preserve
HTML, percent sequences, quotes, or backslashes exactly. Do not recommend
sanitize_text_field() merely to silence a sniff. Require strict type/shape,
encoding/size/domain validation, a safe sink, and escaped output. See
reference.md for the exact-preservation pattern.
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.
WordPress 7.1 expanded what Core KSES accepts: tabindex is a global allowed
attribute, and safe inline CSS recognizes additional gradient, transform,
clip-path, and SVG presentation forms. This is not a bypass and does not make
raw HTML safe, but code must not rely on older Core stripping those values as
its business rule. When the product needs a narrower policy, pass an explicit
allowlist to wp_kses() and test it on every supported Core version. Keep
escaping at output even after KSES sanitization.
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, %i table/column identifiers
(WordPress 6.2+). Still use a semantic allowlist for user-selected columns,
tables, and sort directions: %i quotes an identifier but does not decide
whether that identifier is allowed by the feature.
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".
- Cookie-authenticated writes need
check_ajax_referer(). Public read-only
handlers do not automatically need a nonce; use one only when it protects
a browser action, and never treat a guest nonce as authorization.
- 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,
'type' => 'integer',
'minimum' => 1,
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'absint',
],
],
] );
Findings to flag:
permission_callback missing, or __return_true on a non-public route.
- No
args schema and no equivalent validation in the callback. A route may
validate manually, but a schema is preferred because it is discoverable
and runs consistently before permission and endpoint callbacks.
- 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.
False-positive guards
- Accept exact preservation when its validation, sink, and output contracts are
explicit.
- Do not require a nonce for read-only public endpoints, cron, WP-CLI, or signed
non-cookie requests; identify the actual trust boundary.
- Do not flag code-generated
IN placeholders as injection when the placeholder
string contains only generated %s/%d tokens and all values reach
$wpdb->prepare().
What this skill does NOT cover
- Cryptographic correctness (key derivation, signing schemes).
- Business-logic flaws (race conditions, IDOR beyond capability checks).
- Retry/idempotency/partial-failure flaws in bulk writes — use
wp-batch-mutation-audit.
- Metadata slashing/revision/multi-row/serialization — use
wp-metadata-api.
- 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 and recommend applicable deeper skills in the report footer.
Report format
# Security audit: <plugin name>
Scope: <files reviewed>
Date: <YYYY-MM-DD>
## HIGH
1. <file>:<line> — <issue>
Evidence: <Reproduced | Source-proven>
<code>
Fix: <code>
## MEDIUM
...
## LOW / Hardening
...
## Out of scope
- <thing not checked>
## Requires environment validation
- <hypothesis, missing deployment property, exact acceptance test>
References
1---2name: wp-security-audit3description: Audits WordPress plugin or theme PHP code for the most common security mistakes — missing nonce checks, capability checks, input normalization/validation, output escaping, unslashing, SQL preparation, AJAX nopriv exposure, file/path traversal, and unsafe redirects. Use when reviewing pull requests, before releasing a plugin, when the user asks "is this secure", or when handling code that touches $_GET / $_POST / $_REQUEST / $_COOKIE / $_FILES / $_SERVER, admin-ajax / admin-post, REST endpoints, options, user meta, custom DB queries, or file uploads.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).386. Mark the evidence status separately from severity:39 - **Reproduced** — a controlled test reached the sink or observed the effect;40 - **Source-proven** — the complete deterministic path and prerequisites are41 visible in the inspected code/core contracts;42 - **Environment-dependent hypothesis** — the effect needs a deployment43 property or integration that was not available to test.4445Do not present an environment-dependent hypothesis as a confirmed finding. Put46it under limitations or required validation, name the missing environment, and47do not promote it into a reusable skill rule until it is reproduced or the48relevant runtime contract is verified. Severity describes impact and49exploitability; it does not compensate for weak evidence.5051Do NOT silently rewrite the file. Produce a report first; only edit if the52user asks you to apply fixes.5354## Critical checks5556### 1. Nonce verification on state-changing requests5758Any cookie-authenticated browser handler that *writes* (saves an option,59updates meta, deletes a post, sends an email, or mutates anything) must60verify request intent with a nonce.6162- Forms: `wp_nonce_field( 'action_name', '_wpnonce' )` →63 `check_admin_referer( 'action_name' )` in handler.64- AJAX: `wp_create_nonce( 'action_name' )` → `check_ajax_referer( 'action_name', 'nonce' )`.65- REST: rely on cookie auth + the built-in `_wpnonce` (`wp-api` nonce) for66 logged-in routes; for `permission_callback` use a real capability check.6768A nonce is not authentication or authorization. Signed webhooks,69Application Password/OAuth clients, cron, and WP-CLI use their own trust70boundary instead of a WordPress nonce. Guest nonces do not identify a guest;71public writes also need abuse controls such as throttling, replay protection,72or CAPTCHA where appropriate. A cacheable, read-only public endpoint does not73automatically need a nonce.7475**Common mistake:** verifying the nonce inside an `if` whose `else` branch76still does the write. The nonce check must short-circuit.7778### 2. Capability checks (authorization)7980Authentication ≠ authorization. A logged-in subscriber is still a user.8182- Admin actions: `current_user_can( 'manage_options' )` or a more83 specific capability (`edit_posts`, `edit_post` with object ID,84 `manage_woocommerce` etc.).85- Object-level actions MUST pass the object ID:86 `current_user_can( 'edit_post', $post_id )` — the ID-less form is wrong.87- REST `permission_callback` must enforce the route's actual access policy.88 `__return_true` is dangerous on privileged writes, but can be intentional89 for genuinely public endpoints. Signed webhook routes should verify the90 signature before mutation, preferably in `permission_callback`.91- In multisite, `is_user_member_of_blog()` is a membership predicate, not a92 capability check. WordPress 7.1 adds the `is_user_member_of_blog` filter, but93 it runs only after both the user and an active site have resolved. Returning94 `true` can affect core REST user/application-password checks and admin UI95 visibility globally; do not use the filter to manufacture authorization.96 Keep an object-appropriate `current_user_can()` check at the protected sink.9798### 3. Input: unslash → normalize when needed → validate99100WordPress slashes superglobals. First recover the domain value, then choose101lossy normalization only when the field's meaning permits it, and always102validate the semantic contract:103104```php105$raw = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';106$email = sanitize_email( $raw );107if ( ! is_email( $email ) ) { /* reject */ }108```109110- Missing `wp_unslash` before normalization can leak transport slashes into111 stored data. Do not unslash values that did not come from a slashed boundary.112- Choose the normalizer by field meaning: text, textarea, email, URL, key,113 integer, allowed HTML, and filesystem path need different contracts. See the114 extended `reference.md` map.115- Never trust `$_SERVER['HTTP_*']` headers without sanitizing; they're116 attacker-controlled.117118Sanitization is not a ritual and is often lossy. Migration, import/export,119database repair, code-editor, and opaque meta-value tools may need to preserve120HTML, percent sequences, quotes, or backslashes exactly. Do not recommend121`sanitize_text_field()` merely to silence a sniff. Require strict type/shape,122encoding/size/domain validation, a safe sink, and escaped output. See123`reference.md` for the exact-preservation pattern.124125### 4. Output escaping (XSS)126127Escape **at the point of output**, in the right context:128129- HTML body: `esc_html( $x )`130- HTML attribute: `esc_attr( $x )`131- URL in `href`/`src`: `esc_url( $x )`132- Inside `<script>` JSON: `wp_json_encode( $x )`, never raw concatenation133- Translated strings with placeholders: escape the **template** AND134 the **substituted value** separately. `esc_html__()` only escapes135 the static template; `printf( esc_html__( '%s', 'td' ), $name )` is136 XSS if `$name` contains markup. Correct form:137 `printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) )`.138- Already-HTML content (post content): `wp_kses_post( $x )`139140`echo $foo;` where `$foo` came from input or DB without escaping → XSS.141This is the most common finding in plugin audits.142143WordPress 7.1 expanded what Core KSES accepts: `tabindex` is a global allowed144attribute, and safe inline CSS recognizes additional gradient, transform,145`clip-path`, and SVG presentation forms. This is not a bypass and does not make146raw HTML safe, but code must not rely on older Core stripping those values as147its business rule. When the product needs a narrower policy, pass an explicit148allowlist to `wp_kses()` and test it on every supported Core version. Keep149escaping at output even after KSES sanitization.150151### 5. SQL: always prepare152153```php154// WRONG155$wpdb->get_results( "SELECT * FROM x WHERE id = $id" );156157// RIGHT158$wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );159```160161- `%d` integers, `%f` floats, `%s` strings, `%i` table/column identifiers162 (WordPress 6.2+). Still use a semantic allowlist for user-selected columns,163 tables, and sort directions: `%i` quotes an identifier but does not decide164 whether that identifier is allowed by the feature.165- `LIKE` needs `$wpdb->esc_like()` BEFORE `prepare()`:166 `$like = '%' . $wpdb->esc_like( $term ) . '%';`167- Prefer WP_Query / get_posts / get_users over raw SQL where possible.168169### 6. AJAX endpoints170171Two hooks, two meanings — confuse them and you ship a vulnerability:172173- `wp_ajax_{action}` — fires only for **logged-in** users.174- `wp_ajax_nopriv_{action}` — fires for **logged-out** users.175176Rules:177- Register `nopriv` ONLY if the feature is genuinely meant for guests178 (e.g. public search, login form). Never copy-paste both registrations179 "to be safe".180- Cookie-authenticated writes need `check_ajax_referer()`. Public read-only181 handlers do not automatically need a nonce; use one only when it protects182 a browser action, and never treat a guest nonce as authorization.183- The `nopriv` handler must NOT perform actions that only logged-in users184 should do (saving prefs, accessing other users' data, etc.).185- End with `wp_send_json_success` / `wp_send_json_error`, not `echo` + `die`.186187### 7. admin-post and form handlers188189`admin_post_{action}` and `admin_post_nopriv_{action}` follow the same190rules as AJAX. Plus: redirect with `wp_safe_redirect()` + `exit;`. Never191redirect with `wp_redirect( $_GET['redirect_to'] )` without validating192against an allowlist — that's an open-redirect.193194### 8. REST API routes195196```php197register_rest_route( 'myplugin/v1', '/save', [198 'methods' => 'POST',199 'callback' => 'myplugin_save',200 'permission_callback' => function () {201 return current_user_can( 'manage_options' );202 },203 'args' => [204 'id' => [205 'required' => true,206 'type' => 'integer',207 'minimum' => 1,208 'validate_callback' => 'rest_validate_request_arg',209 'sanitize_callback' => 'absint',210 ],211 ],212] );213```214215Findings to flag:216- `permission_callback` missing, or `__return_true` on a non-public route.217- No `args` schema and no equivalent validation in the callback. A route may218 validate manually, but a schema is preferred because it is discoverable219 and runs consistently before permission and endpoint callbacks.220- Returning raw DB rows including sensitive columns (`user_pass`,221 `user_activation_key`, private meta).222223### 9. File operations224225- Uploads: validate MIME via `wp_check_filetype_and_ext()`, store via226 `wp_handle_upload()`, never trust the client-provided extension or MIME.227- Path joins with user input: after building, `realpath()` and check the228 result starts with the intended base dir. Otherwise: path traversal.229- Never `include` / `require` a path containing user input.230231### 10. Secrets and information disclosure232233- No API keys or DB credentials in the plugin source. Use options or234 constants in `wp-config.php`.235- `WP_DEBUG_DISPLAY` should be off in prod; flag any `var_dump`,236 `print_r`, `error_log( $sensitive )` left in handlers.237- Don't leak stack traces, full SQL, or user enumeration via error238 messages ("user not found" vs "wrong password" — pick one).239240### 11. Redirects241242- Use `wp_safe_redirect()` for any URL that may be influenced by input.243- Always `exit;` after a redirect — execution continues otherwise.244245### 12. Cron and background jobs246247- `wp_schedule_event` callbacks run with no current user. If the job248 performs privileged work, do not trust any "stored intent" without249 re-validating; treat persisted user input as untrusted.250251## False-positive guards252253- Accept exact preservation when its validation, sink, and output contracts are254 explicit.255- Do not require a nonce for read-only public endpoints, cron, WP-CLI, or signed256 non-cookie requests; identify the actual trust boundary.257- Do not flag code-generated `IN` placeholders as injection when the placeholder258 string contains only generated `%s`/`%d` tokens and all values reach259 `$wpdb->prepare()`.260261## What this skill does NOT cover262263- Cryptographic correctness (key derivation, signing schemes).264- Business-logic flaws (race conditions, IDOR beyond capability checks).265- Retry/idempotency/partial-failure flaws in bulk writes — use266 **`wp-batch-mutation-audit`**.267- Metadata slashing/revision/multi-row/serialization — use **`wp-metadata-api`**.268- Third-party library CVEs — run `composer audit` separately.269- Frontend JS XSS — different skill.270- Server / hosting hardening (file perms, disable_functions, etc.).271- Object injection, SSRF, CSRF on GET, mass assignment, file include,272 mail/zip injection, timing comparison, TOCTOU races — these are273 covered by **`wp-security-deep`**. Run it after this one.274- Hardcoded credentials, weak randomness for tokens, password275 storage, cookie flags, secrets in logs — covered by276 **`wp-security-secrets`**. Run it whenever auth or third-party277 integrations are in scope.278279State this scope and recommend applicable deeper skills in the report footer.280281## Report format282283```284# Security audit: <plugin name>285Scope: <files reviewed>286Date: <YYYY-MM-DD>287288## HIGH2891. <file>:<line> — <issue>290 Evidence: <Reproduced | Source-proven>291 <code>292 Fix: <code>293294## MEDIUM295...296297## LOW / Hardening298...299300## Out of scope301- <thing not checked>302303## Requires environment validation304- <hypothesis, missing deployment property, exact acceptance test>305```306307## References308309- Detailed examples of each finding type, before/after: `reference.md`310- Real-world snippets with the fix applied: `examples/`311- WordPress core: [Plugin Security Handbook](https://developer.wordpress.org/plugins/security/) and [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities/)312- Official documentation: <https://developer.wordpress.org/apis/security/>313- Official documentation: <https://developer.wordpress.org/reference/functions/wp_verify_nonce/>314- Official documentation: <https://developer.wordpress.org/reference/functions/current_user_can/>315- WordPress 7.1 source: `wp-includes/user.php` (`is_user_member_of_blog`).