When you’re shipping code that runs on over a million websites, the stakes around security stop being abstract. A bug isn’t just a bug — it’s a potential entry point into a million different businesses, personal blogs, and e-commerce stores. The lessons that stuck deepest didn’t come from documentation. They came from near-misses, late-night Slack messages, and one vulnerability report that kept me up all night that I’ll get to shortly.
This post covers what I’ve learned about writing secure WordPress plugins. Some of it is foundational stuff you’ve probably heard before but maybe haven’t fully internalized. Some of it is more nuanced. All of it has been earned.
The Mindset Shift: Trust Nothing
The single most important thing I can tell you is this: treat every piece of incoming data as hostile. Every $_POST value, every query string parameter, every REST API payload, every value pulled from the database that a user could have touched at any point — assume it’s malicious until you’ve explicitly made it safe.
This isn’t paranoia. It’s just accurate. A form field on your settings page looks harmless. But what if an attacker tricks an admin into submitting a crafted request? What if a different plugin has already written malicious data into a postmeta field your code reads later? The attack surface in a WordPress environment is enormous because WordPress is a platform, and your plugin is one tenant in a building with hundreds of others.
Nonce Verification: Your First Line of Defense
Nonces are how WordPress prevents Cross-Site Request Forgery (CSRF). They’re time-limited, user-specific tokens that verify a request originated from your own UI rather than from an attacker’s crafted page.
The pattern is simple but I’ve seen it skipped or half-implemented more times than I’d like to admit. Here’s the correct approach for a form submission handler:
add_action( 'admin_post_my_plugin_save_settings', 'my_plugin_save_settings' );
function my_plugin_save_settings() {
// 1. Verify the nonce — bail immediately if it fails.
if ( ! isset( $_POST['my_plugin_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['my_plugin_nonce'] ) ), 'my_plugin_save_settings_action' ) ) {
wp_die( esc_html__( 'Security check failed.', 'my-plugin' ) );
}
// 2. Check capabilities — more on this below.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'my-plugin' ) );
}
// 3. Now process the data.
}
Notice the order: nonce first, capability check second, data processing third. Never flip those. And always output the nonce in your form:
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="my_plugin_save_settings">
<?php wp_nonce_field( 'my_plugin_save_settings_action', 'my_plugin_nonce' ); ?>
<!-- rest of your form -->
</form>
One subtle thing: for AJAX handlers, use check_ajax_referer() instead of manual wp_verify_nonce(). It does the same thing but also calls wp_die() on failure automatically, which reduces the chance of you forgetting to bail.
Capability Checks: Who’s Actually Allowed to Do This?
Nonce verification proves the request is legitimate. Capability checks prove the user is allowed to perform the action. You need both.
The mistake I see most often is using overly broad capabilities. manage_options is fine for plugin settings pages. But if you’re building something like a form submission viewer, a subscriber should be able to see their own submissions — not everyone’s. Think carefully about what each action actually requires.
// Too broad — any admin can delete any user's data
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'No access.' );
}
// Better — check the specific capability, and also verify ownership
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( 'No access.' );
}
// Even more precise for custom post types
if ( ! current_user_can( 'delete_others_posts' ) && get_post_field( 'post_author', $post_id ) != get_current_user_id() ) {
wp_die( 'No access.' );
}
For REST API endpoints, capability checks belong in the permission_callback. Never leave that as __return_true in production code unless the endpoint genuinely serves public data:
register_rest_route( 'my-plugin/v1', '/settings', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'my_plugin_get_settings',
'permission_callback' => function() {
return current_user_can( 'manage_options' );
},
] );
Input Sanitization: The Right Tool for Each Job
WordPress gives you a whole toolkit of sanitization functions. The mistake is using the wrong one, or using none at all. Here’s a quick reference for the ones I reach for most:
sanitize_text_field()— plain text input, strips tags and extra whitespacesanitize_email()— email addressessanitize_url()/esc_url_raw()— URLs (useesc_url_raw()when saving,esc_url()when outputting)absint()— integers that should always be positiveintval()— integers that can be negativewp_kses_post()— rich text that should allow basic HTMLwp_kses()with a custom allowed tags array — when you need tight control over allowed HTMLsanitize_key()— option keys, slugs, array keys
A pattern I follow for settings saves: sanitize each field individually based on what it actually is, not just run everything through sanitize_text_field() wholesale.
function my_plugin_sanitize_settings( $input ) {
$sanitized = [];
$sanitized['api_key'] = sanitize_text_field( $input['api_key'] ?? '' );
$sanitized['redirect_url'] = esc_url_raw( $input['redirect_url'] ?? '' );
$sanitized['max_items'] = absint( $input['max_items'] ?? 10 );
$sanitized['enable_cache'] = ! empty( $input['enable_cache'] ) ? 1 : 0;
$sanitized['custom_css'] = wp_strip_all_tags( $input['custom_css'] ?? '' );
return $sanitized;
}
Don’t forget wp_unslash() before sanitizing $_POST data. WordPress (via PHP’s legacy magic quotes behavior) can add slashes that your sanitization functions don’t strip, leading to weirdly escaped data in the database.
$wpdb->prepare(): Non-Negotiable for Custom Queries
If you’re using $wpdb for custom database queries — which you sometimes need to in a serious plugin — $wpdb->prepare() is not optional. SQL injection is one of the most catastrophic vulnerabilities a plugin can have, and it’s completely preventable.
global $wpdb;
// NEVER do this
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = " . $_GET['user_id']
);
// Always do this
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM %i WHERE user_id = %d",
$wpdb->prefix . 'my_table',
absint( $_GET['user_id'] )
)
);
The %i placeholder (added in WordPress 6.2) is for identifiers like table and column names. Use %d for integers, %s for strings, %f for floats. And yes, even if you’ve already run absint() on the value, still use %d in the prepared statement. Defense in depth.
XSS Prevention: Escaping on Output
Cross-Site Scripting happens when unescaped data ends up rendered as HTML in a browser. The rule is: escape as late as possible, as close to the output as possible. Sanitizing on input doesn’t mean you can skip escaping on output — data can change between when you save it and when you display it, and stored XSS is a very real attack category.
// In a template or view file
<p><?php echo esc_html( get_option( 'my_plugin_title' ) ); ?></p>
<a href="<?php echo esc_url( get_option( 'my_plugin_url' ) ); ?>">Click here</a>
<div class="<?php echo esc_attr( $css_class ); ?>"></div>
// For output inside a script tag
<script>
var config = <?php echo wp_json_encode( $safe_array ); ?>;
</script>
The escaping functions map to context: esc_html() for text content, esc_attr() for HTML attribute values, esc_url() for href and src attributes, esc_js() for inline JS strings, and wp_json_encode() when passing PHP data to JavaScript.
One thing that catches people off guard: translation functions. If you’re doing echo __( 'some string', 'my-plugin' ), you still need to escape it. A compromised translations file could inject HTML. Use esc_html_e() or esc_html__()` instead of the bare translation functions when outputting text.
The War Story: A Stored XSS That Nearly Shipped
I want to share something that happened during my time working on a page builder plugin. We had a feature that allowed users to configure custom CSS classes for their page sections through a UI text field. The field was sanitized on save with sanitize_text_field(), which strips tags — so we felt good about it.
What we missed: the sanitized value was later passed into a Vue component as a prop, which then set it as v-html on an element during the preview render inside the builder’s iframe. sanitize_text_field() strips HTML tags, but it doesn’t prevent all XSS vectors in every context. In this specific case, the value was being interpolated into a style attribute string in the component template without attribute-level escaping, and a crafted input with a CSS expression could break out of the attribute context.
We caught it in an internal security review before it shipped, but it was close. The fix was two-pronged: sanitize with a stricter allowlist on save (CSS class names should only contain alphanumerics, hyphens, and underscores), and escape properly in the component template using a utility function rather than raw string interpolation.
The lesson wasn’t just “escape better.” It was that security vulnerabilities often live at the boundary between systems — in this case, between PHP and JavaScript, between server-side validation and client-side rendering. Those handoff points are where you need to be most careful.
PHPCS and WordPress Coding Standards: Automating the Boring Parts
At scale, manual code review for security issues doesn’t cut it. You need automated tooling. PHP_CodeSniffer with the WordPress Coding Standards ruleset catches a lot of the common mistakes — missing escaping, direct database queries, use of deprecated functions, and more.
Getting it set up is straightforward:
# Install via Composer
composer require --dev squizlabs/php_codesniffer wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
# Create a phpcs.xml in your plugin root
<?xml version="1.0"?>
<ruleset name="My Plugin">
<description>My Plugin PHPCS ruleset</description>
<file>.</file>
<arg name="basepath" value="."></arg>
<arg name="extensions" value="php"></arg>
<arg name="parallel" value="8"></arg>
<arg value="sp"></arg>
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<rule ref="WordPress"></rule>
<rule ref="WordPress.Security"></rule>
</ruleset>
Then run it with ./vendor/bin/phpcs. Hook it into your CI pipeline so it runs on every PR. I’ve found that making it a hard block on merge is the only way to ensure it actually gets followed — developers (myself included) will happily ignore a warning that doesn’t stop the deploy.
Beyond PHPCS, I also use Patchstack’s free plugin scanner on major releases. It’s not a silver bullet, but it catches patterns that static analysis misses.
A Practical Security Checklist
Here’s what I run through before marking a feature complete on any plugin I work on:
- Every form submission has a nonce. Generated with
wp_nonce_field()orwp_create_nonce(), verified before any processing happens. - Every privileged action has a capability check. Not just “is the user logged in” but “does this specific user have the specific permission for this specific action.”
- Every piece of user input is sanitized. With the right function for the data type, immediately after it’s received.
- Every custom database query uses
$wpdb->prepare(). No exceptions. - Every output is escaped. With the context-appropriate function, at the point of output.
- REST API endpoints have proper
permission_callback. Never__return_trueon authenticated routes. - File operations validate and sanitize paths. No user-supplied data goes directly into
file_get_contents(),include, or similar — userealpath()and check it’s inside your expected directory. - PHPCS passes cleanly. Zero errors, zero warnings related to security sniffs.
- Options and transients storing sensitive data are handled carefully. Don’t store plaintext API secrets in options if you can avoid it; at minimum, document that they’re stored and let users know.
- Third-party HTTP requests validate responses. Check
is_wp_error(), check response codes, don’t blindly trust data coming back from external APIs.
Closing Thoughts
Security work is repetitive. Most of it is repetitive — the same sanitize/escape/verify loop applied consistently across hundreds of touch points in a codebase. But that repetition is exactly why it’s easy to slip up, and exactly why you need to build habits and tooling that make doing the right thing the path of least resistance.
Working at the scale of a million-plus active installs has given me a deep respect for how much trust users place in the software they install. When someone activates your plugin, they’re not just adding a feature to their site — they’re extending trust. Taking security seriously is the most basic way to honor that.
If you’ve made it this far and you’re working on a plugin right now, do yourself a favor: go run PHPCS on it today. The output might be humbling. It usually is. But that’s the point — better to know now than to find out the hard way.