1. Block direct file access
Every PHP file in your plugin should refuse to run if it is hit directly via the browser, because the only legitimate caller is WordPress itself. The one-liner at the top of every file:
defined( 'ABSPATH' ) || exit;
It looks trivial because it is. But it stops a whole class of issues where a file is loadable on its own and somebody discovers it leaks information, runs code or causes errors that disclose a path. Make this part of your editor snippet so you never forget.
2. Sanitize on input
"User input" doesn't only mean a form a visitor fills in. It is anything you didn't generate yourself: $_GET, $_POST, $_REQUEST, $_COOKIE, REST request bodies, third-party API responses, even values stored in the database that originally came from one of those sources. Treat all of it as hostile.
WordPress ships sanitizers for the common shapes:
sanitize_text_field()— short single-line strings (names, titles).sanitize_textarea_field()— multi-line plain text.sanitize_email(),sanitize_url(),sanitize_key(),sanitize_title().absint()— for IDs and counts.wp_kses_post()— for HTML you want to allow but constrain.
The pattern: never read a superglobal directly into a variable that you then act on. Always wrap it. The line
$id = absint( $_GET['id'] ?? 0 );
is shorter than the unsafe version and removes an entire bug category. If a sanitizer doesn't exist for the shape you need (for example, a JSON object with a known schema), write one — and make it return a value-object, not a flat array, so that anywhere downstream knows it has been validated.
3. Escape on output
Sanitization protects what you store. Escaping protects what you show. They are not the same thing and neither one substitutes for the other. The rule: escape at the latest possible moment, with the function that matches the destination context.
esc_html()— text inside a tag.esc_attr()— values inside an HTML attribute.esc_url()— anything used inhreforsrc.esc_js()— values interpolated into inline JS (rare; prefer JSON).wp_kses()with an explicit allowed-tag list — when you must render user-provided HTML.
A useful editor habit: every time you type echo or <?=, immediately type the matching escape function around the value. Re-fitting esc_html later is how you miss things.
4. Use nonces on every state-changing request
A nonce ("number used once") is WordPress's CSRF defence. Any form that performs a side-effect — saving, deleting, sending email, anything that isn't a pure read — needs one.
// Generating
wp_nonce_field( 'myplugin_save_settings', 'myplugin_nonce' );
// Verifying
if ( ! isset( $_POST['myplugin_nonce'] )
|| ! wp_verify_nonce( $_POST['myplugin_nonce'], 'myplugin_save_settings' ) ) {
wp_die( esc_html__( 'Security check failed.', 'my-plugin' ), 403 );
}
For AJAX, use check_ajax_referer( 'myplugin_save_settings' ) at the top of the handler — it does both the existence check and the verification in one call and dies on failure. For the REST API, your permission_callback is the equivalent (covered below).
5. Check capabilities, not just "logged in"
is_user_logged_in() is a terrible authorisation check. A subscriber is logged in. A spammer who registered to comment is logged in. The check you want is "does this user have the capability to do this specific thing?"
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission.', 'my-plugin' ), 403 );
}
If your plugin introduces something that doesn't fit the built-in capability set (editing a custom post type with bespoke roles, for example), register your own capabilities at activation and check those. The general rule: nonces prove the request is intentional, capabilities prove the user is allowed. You need both.
6. Prepared statements for every query
If you are calling $wpdb->query() with a string that contains a variable, you have a SQL injection bug. There are two safe paths:
- Use the higher-level helpers (
$wpdb->insert,$wpdb->update,$wpdb->delete) which build prepared queries from your data array. - Use
$wpdb->prepare()with placeholders.
// Wrong
$wpdb->query( "SELECT * FROM {$wpdb->prefix}orders WHERE customer_id = $id" );
// Right
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE customer_id = %d",
$id
) );
One detail people miss: the table name and the column name cannot be parameterised by prepare(). If those come from user input, you need to validate them against an allow-list of known values before interpolating. Never accept "the column name to sort by" from a query string and feed it straight into a query.
7. REST API permission_callback is not optional
Every register_rest_route() call must include a permission_callback. Returning '__return_true' means "this endpoint is open to the entire internet, including bots and scrapers" — that is sometimes what you want, but it must be a deliberate choice, not the default.
register_rest_route( 'myplugin/v1', '/orders', [
'methods' => 'POST',
'callback' => [ $controller, 'create' ],
'permission_callback' => static fn () => current_user_can( 'edit_shop_orders' ),
'args' => [
'amount' => [ 'required' => true, 'sanitize_callback' => 'absint' ],
'note' => [ 'sanitize_callback' => 'sanitize_text_field' ],
],
] );
Use args with sanitize_callback on every parameter. WordPress will reject anything missing or invalid before your callback runs. That alone removes a lot of defensive code from your handler.
8. File uploads and path traversal
Anywhere your plugin accepts a file from the user — even just an image — there are three things to lock down:
- Use
wp_handle_upload()ormedia_handle_upload()rather than rolling your own move logic. They run the file through MIME-sniffing and the WordPress allowed-types list. - Never trust the supplied filename. Use it for display, not for the on-disk name. Generate the path yourself with
wp_unique_filename(). - Resolve any path you read with
realpath()and check it is still under your intended directory before opening it. The classic "download invoice" feature where the filename comes from a query string is the canonical path-traversal vulnerability.
If your plugin lets users include files dynamically (templates, language packs, custom CSS files), the same rule applies: validate against a known directory, reject anything containing .. or absolute paths.
9. Secrets, options and the wp-config divide
API keys, signing secrets and other "this must never leak" values do not belong in wp_options in plain text. They especially do not belong in plugin code committed to git. The right home is wp-config.php (or environment variables loaded into it), and the right plugin pattern is to read them with a fallback:
private function api_key(): string {
if ( defined( 'MYPLUGIN_API_KEY' ) ) {
return MYPLUGIN_API_KEY;
}
return (string) get_option( 'myplugin_api_key', '' );
}
That gives advanced users a secure way to store the secret outside the database while still letting beginners paste it into a settings field. If you absolutely must store it in wp_options, mark it as protected with add_option( ..., '', false, 'no' ) so it isn't autoloaded, and consider encrypting it with a key derived from AUTH_KEY.
And: never log a secret. Audit your debug output for variable dumps that include API responses or request headers.
10. When something does go wrong
Eventually, somebody will find a vulnerability in code you wrote. The difference between a plugin that survives a CVE and one that gets pulled from WordPress.org is how fast you respond. Have these set up before you need them:
- A
SECURITY.mdin the plugin's repo with a contact email and your disclosure policy. - A monitored inbox for that email.
- A patch-release process you can run end-to-end in under 24 hours.
- Honest, dated changelog entries that say "security fix" rather than "minor improvements".
Quiet patches are how trust gets lost. Visible, fast patches are how trust is earned.
Make it habit, not vigilance
The reason this list works as a checklist is that none of the items require thinking under pressure. They are decisions you make once, encode into snippets, base classes and permission callbacks, and then never revisit. That is the actual deliverable: not "we are careful with security" but "the unsafe path takes more typing than the safe one."
Want a security review of an existing plugin before it ships, or after a customer flagged something? Send me a brief — I do plugin audits as fixed-fee engagements with a written report.