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.phpaddition. - 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
ABSPATHguard, 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 viawp_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
php// ❌ Insecure: file executes if requested directly over HTTP. <?php function my_plugin_init() { /* ... */ }
php// ✅ Secure: bail unless loaded within WordPress. <?php defined( 'ABSPATH' ) || exit; function my_plugin_init() { /* ... */ }
Mistake 2 — Doing the work before the security checks
php// ❌ 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' ); }
php// ✅ 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
php// ❌ 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 );
php// ✅ 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
php// ❌ Insecure: feature ships enabled, capability defaults wide open. add_option( 'my_plugin_allow_uploads', true );
php// ✅ 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
- Every PHP file opens with
defined( 'ABSPATH' ) || exit;. - Functions, hooks, and options are uniquely prefixed.
- Each request handler verifies nonce, then capability, then sanitizes input.
- All custom DB access uses
$wpdb->prepare(). - All dynamic output is escaped at the point of echo.
- Options have safe defaults and are validated on save.
- Remote requests use the WP HTTP API (
wp_remote_*), notfile_get_contents/cURL. - No secrets, keys, or credentials are hard-coded or sent to the browser.
- Scripts are enqueued and given data via
wp_localize_script().


