Secure plugin & theme development (baseline)
When to use this skill
Use this skill at the start of any WordPress development work and whenever you add
a feature that crosses a trust boundary:
- Creating a new plugin main file or theme
functions.php addition.
- Registering hooks (
add_action / add_filter) that handle input or render output.
- Adding admin pages, settings, shortcodes, blocks, widgets, or REST routes.
- Reviewing an existing plugin to bring it up to a secure baseline.
This is the router skill. Follow the
security decision tree: choose the entry path
(browser/API, renderer, cron, or CLI), then add the relevant data and policy
branches. It explains when to combine focused skills and when browser nonce
checks do not apply; do not load every skill for every task.
Core principles (and why they matter)
- Never trust input; always escape output. Every value from
$_GET, $_POST,
$_REQUEST, $_COOKIE, the database, or a remote API is untrusted until sanitized,
and untrusted again the moment it is echoed. These are two separate jobs.
- Block direct file access. Plugin files are reachable by URL. Without an
ABSPATH
guard, an attacker can execute them outside WordPress, bypassing all your checks.
- Separate authentication, CSRF, and authorization. Use a nonce for
cookie-authenticated state changes and an appropriate capability/object check for
privileged actions. REST API credentials, cron, and CLI have different trust
models; follow the decision tree rather than adding browser checks everywhere.
- Use core APIs, not hand-rolled code. Prefer maintained sanitize/escape/DB/HTTP
APIs, but choose the API and its arguments for the actual trust boundary.
- Least privilege by default. Default options to the safe value, scope capabilities
tightly, and expose the minimum surface.
- Fail closed. On any failed check, stop and return an error — never fall through.
Step-by-step implementation
- Guard the file:
defined( 'ABSPATH' ) || exit; at the top of every PHP file.
- Namespace everything: prefix functions, hooks, options, and globals (e.g.
my_plugin_*) to avoid collisions and accidental overrides.
- Choose the handler trust model using the decision tree:
- Apply transport-appropriate authentication and CSRF protection.
- Check authority over the action and specific resource.
- Validate input shape/type, unslash WordPress-slashed request input, and sanitize.
- Use
$wpdb->prepare() for dynamic values in custom queries.
- Escape at each output sink for its actual context.
- Set safe defaults for all options; validate on save and on read.
- Enqueue assets properly (
wp_enqueue_script/style) and pass data via
wp_localize_script() rather than inline-echoing PHP into JS.
- Keep secrets out of the repo and out of client-readable output.
Supporting references
| Reference |
Load when |
| Secure plugin baseline checklist |
Before final verification of the secure plugin baseline controls. |
| Choose the security review path |
Selecting focused skills for the entry point and all relevant data and risk branches. |
| Secure plugin skeleton |
Implementing a capability-gated plugin admin page and the full settings-save flow. |
Common AI mistakes / anti-patterns
Mistake 1 — No ABSPATH guard
// ❌ Insecure: file executes if requested directly over HTTP.
<?php
function my_plugin_init() { /* ... */ }
// ✅ Secure: bail unless loaded within WordPress.
<?php
defined( 'ABSPATH' ) || exit;
function my_plugin_init() { /* ... */ }
Mistake 2 — Doing the work before the security checks
// ❌ Insecure: option saved before anything is verified.
function my_plugin_save() {
update_option( 'my_opt', $_POST['val'] );
check_admin_referer( 'my_plugin_save' );
current_user_can( 'manage_options' );
}
// ✅ Secure: verify, authorize, sanitize, THEN act.
function my_plugin_save() {
check_admin_referer( 'my_plugin_save', 'my_plugin_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Forbidden', 'my-plugin' ), 403 );
}
$val = isset( $_POST['val'] ) ? sanitize_text_field( wp_unslash( $_POST['val'] ) ) : '';
update_option( 'my_opt', $val );
}
Mistake 3 — Rolling your own instead of using core APIs
// ❌ Insecure: manual SQL, manual escaping, raw remote fetch.
$rows = $wpdb->get_results( "SELECT * FROM t WHERE id = " . $_GET['id'] );
echo "<a href=" . $_GET['url'] . ">x</a>";
$body = file_get_contents( $remote_url );
// ✅ Secure: prepared query, escaped output, HTTP API.
$id = absint( $_GET['id'] ?? 0 );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}t WHERE id = %d", $id ) );
echo '<a href="' . esc_url( wp_unslash( $_GET['url'] ?? '' ) ) . '">x</a>';
$response = wp_remote_get( $remote_url );
$body = is_wp_error( $response ) ? '' : wp_remote_retrieve_body( $response );
Mistake 4 — Unsafe defaults
// ❌ Insecure: feature ships enabled, capability defaults wide open.
add_option( 'my_plugin_allow_uploads', true );
// ✅ Secure: default to the safe value; opt-in to risk.
add_option( 'my_plugin_allow_uploads', false );
Correct code examples
A minimal but complete secure plugin skeleton — ABSPATH guard, an admin page behind a
capability, and the full verify → authorize → sanitize → act → escape flow — lives in
references/secure-plugin-skeleton.php.
Checklist
Official references
1---2name: secure-plugin-development3description: Use when starting a new WordPress plugin or theme, scaffolding a plugin file, wiring hooks, or adding any feature that handles requests, options, or output. Establishes the secure-by-default baseline — ABSPATH guard, the capability + nonce + sanitize + escape flow, prepared queries, and safe defaults — and routes to the focused security skills for each concern. Apply proactively at the start of any WordPress build.4license: MIT5---67# Secure plugin & theme development (baseline)89## When to use this skill1011Use this skill at the **start** of any WordPress development work and whenever you add12a feature that crosses a trust boundary:1314- Creating a new plugin main file or theme `functions.php` addition.15- Registering hooks (`add_action` / `add_filter`) that handle input or render output.16- Adding admin pages, settings, shortcodes, blocks, widgets, or REST routes.17- Reviewing an existing plugin to bring it up to a secure baseline.1819This is the **router** skill. Follow the20[security decision tree](references/decision-tree.md): choose the entry path21(browser/API, renderer, cron, or CLI), then add the relevant data and policy22branches. It explains when to combine focused skills and when browser nonce23checks do not apply; do not load every skill for every task.2425## Core principles (and why they matter)26271. **Never trust input; always escape output.** Every value from `$_GET`, `$_POST`,28 `$_REQUEST`, `$_COOKIE`, the database, or a remote API is untrusted until sanitized,29 and untrusted again the moment it is echoed. These are two separate jobs.302. **Block direct file access.** Plugin files are reachable by URL. Without an `ABSPATH`31 guard, an attacker can execute them outside WordPress, bypassing all your checks.323. **Separate authentication, CSRF, and authorization.** Use a nonce for33 cookie-authenticated state changes and an appropriate capability/object check for34 privileged actions. REST API credentials, cron, and CLI have different trust35 models; follow the decision tree rather than adding browser checks everywhere.364. **Use core APIs, not hand-rolled code.** Prefer maintained sanitize/escape/DB/HTTP37 APIs, but choose the API and its arguments for the actual trust boundary.385. **Least privilege by default.** Default options to the safe value, scope capabilities39 tightly, and expose the minimum surface.406. **Fail closed.** On any failed check, stop and return an error — never fall through.4142## Step-by-step implementation43441. **Guard the file:** `defined( 'ABSPATH' ) || exit;` at the top of every PHP file.452. **Namespace everything:** prefix functions, hooks, options, and globals (e.g.46 `my_plugin_*`) to avoid collisions and accidental overrides.473. **Choose the handler trust model** using the [decision tree](references/decision-tree.md):48 1. Apply transport-appropriate authentication and CSRF protection.49 2. Check authority over the action and specific resource.50 3. Validate input shape/type, unslash WordPress-slashed request input, and sanitize.51 4. Use `$wpdb->prepare()` for dynamic values in custom queries.52 5. Escape at each output sink for its actual context.534. **Set safe defaults** for all options; validate on save and on read.545. **Enqueue assets properly** (`wp_enqueue_script/style`) and pass data via55 `wp_localize_script()` rather than inline-echoing PHP into JS.566. **Keep secrets out of the repo** and out of client-readable output.5758### Supporting references5960| Reference | Load when |61| --- | --- |62| [Secure plugin baseline checklist](references/checklist.md) | Before final verification of the secure plugin baseline controls. |63| [Choose the security review path](references/decision-tree.md) | Selecting focused skills for the entry point and all relevant data and risk branches. |64| [Secure plugin skeleton](references/secure-plugin-skeleton.php) | Implementing a capability-gated plugin admin page and the full settings-save flow. |6566## Common AI mistakes / anti-patterns6768### Mistake 1 — No ABSPATH guard6970```php71// ❌ Insecure: file executes if requested directly over HTTP.72<?php73function my_plugin_init() { /* ... */ }74```7576```php77// ✅ Secure: bail unless loaded within WordPress.78<?php79defined( 'ABSPATH' ) || exit;8081function my_plugin_init() { /* ... */ }82```8384### Mistake 2 — Doing the work before the security checks8586```php87// ❌ Insecure: option saved before anything is verified.88function my_plugin_save() {89 update_option( 'my_opt', $_POST['val'] );90 check_admin_referer( 'my_plugin_save' );91 current_user_can( 'manage_options' );92}93```9495```php96// ✅ Secure: verify, authorize, sanitize, THEN act.97function my_plugin_save() {98 check_admin_referer( 'my_plugin_save', 'my_plugin_nonce' );99 if ( ! current_user_can( 'manage_options' ) ) {100 wp_die( esc_html__( 'Forbidden', 'my-plugin' ), 403 );101 }102 $val = isset( $_POST['val'] ) ? sanitize_text_field( wp_unslash( $_POST['val'] ) ) : '';103 update_option( 'my_opt', $val );104}105```106107### Mistake 3 — Rolling your own instead of using core APIs108109```php110// ❌ Insecure: manual SQL, manual escaping, raw remote fetch.111$rows = $wpdb->get_results( "SELECT * FROM t WHERE id = " . $_GET['id'] );112echo "<a href=" . $_GET['url'] . ">x</a>";113$body = file_get_contents( $remote_url );114```115116```php117// ✅ Secure: prepared query, escaped output, HTTP API.118$id = absint( $_GET['id'] ?? 0 );119$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}t WHERE id = %d", $id ) );120echo '<a href="' . esc_url( wp_unslash( $_GET['url'] ?? '' ) ) . '">x</a>';121$response = wp_remote_get( $remote_url );122$body = is_wp_error( $response ) ? '' : wp_remote_retrieve_body( $response );123```124125### Mistake 4 — Unsafe defaults126127```php128// ❌ Insecure: feature ships enabled, capability defaults wide open.129add_option( 'my_plugin_allow_uploads', true );130```131132```php133// ✅ Secure: default to the safe value; opt-in to risk.134add_option( 'my_plugin_allow_uploads', false );135```136137## Correct code examples138139A minimal but complete secure plugin skeleton — ABSPATH guard, an admin page behind a140capability, and the full verify → authorize → sanitize → act → escape flow — lives in141[`references/secure-plugin-skeleton.php`](references/secure-plugin-skeleton.php).142143## Checklist144145- [ ] Every PHP file opens with `defined( 'ABSPATH' ) || exit;`.146- [ ] Functions, hooks, and options are uniquely prefixed.147- [ ] Each request handler verifies nonce, then capability, then sanitizes input.148- [ ] All custom DB access uses `$wpdb->prepare()`.149- [ ] All dynamic output is escaped at the point of echo.150- [ ] Options have safe defaults and are validated on save.151- [ ] Remote requests use the WP HTTP API (`wp_remote_*`), not `file_get_contents`/cURL.152- [ ] No secrets, keys, or credentials are hard-coded or sent to the browser.153- [ ] Scripts are enqueued and given data via `wp_localize_script()`.154155## Official references156157- [Security — Common APIs Handbook](https://developer.wordpress.org/apis/security/)158- [Plugin Security — Plugin Handbook](https://developer.wordpress.org/plugins/security/)159- [Checking User Capabilities](https://developer.wordpress.org/plugins/security/checking-user-capabilities/)160- [Data Validation](https://developer.wordpress.org/apis/security/data-validation/)161- [Escaping Data](https://developer.wordpress.org/apis/security/escaping/)162- [HTTP API](https://developer.wordpress.org/plugins/http-api/)163- [OWASP Top Ten](https://owasp.org/www-project-top-ten/)