Skip to content
WordPress 6 min read Sajid Aslam

WordPress Plugin Development Security Best Practices

Most plugin vulnerabilities come from the same handful of mistakes. Here is what secure plugin code does differently, and how to check a plugin you have paid for.

Checklist of security controls in a WordPress plugin request lifecycle

Short answer

Secure WordPress plugin development comes down to a few habits applied everywhere: validate and sanitise every input, escape every output as late as possible, check the user's capability and a nonce before any action that changes data, use prepared statements for database queries, and give every REST endpoint a real permission check. Most plugin vulnerabilities break one of these rules.

Secure WordPress plugin development is not a specialist discipline bolted on at the end. It is a small set of habits applied to every line that touches user input, the database or the page output. Most security problems in WordPress plugins come from breaking one of those habits: trusting input, printing data without escaping it, forgetting a permission check, or building a database query by joining strings.

This article sets out the practices I follow on every plugin, in the order a request passes through the code. It is written for developers and for business owners who want to know what to ask when commissioning a plugin. For site-wide security, such as hosting, logins and updates, see the WordPress security guide.

The principle behind secure WordPress plugin development

Treat every piece of data from outside your code as hostile until it has been checked. That includes form fields, URL parameters, cookies, uploaded files, REST requests, responses from third-party APIs, and data read back from the database that originally came from users.

The official WordPress security handbook for developers puts it the same way: validate and sanitise input, escape output, and use WordPress's own APIs rather than inventing your own.

1. Check who is asking: capabilities

Before a plugin does anything that changes data or reveals private data, it must confirm the current user is allowed to. In WordPress that means current_user_can() with a specific capability, such as manage_options for settings or edit_post for a particular post.

Two common mistakes:

  • Checking whether the user is logged in instead of what they can do. A logged-in subscriber is still a stranger as far as admin actions are concerned.
  • Relying on the admin menu to hide a page. Hiding a link is not a permission check. The handler behind it must check again.

Every AJAX action, admin-post handler, form handler and REST endpoint needs its own capability check, even if the page that calls it already has one.

2. Check the request is genuine: nonces

A nonce is a token tied to the user, the action and a time window. It proves the request came from a form or link your site produced, which stops cross-site request forgery, where another site tricks a logged-in administrator's browser into submitting a request.

  • add a nonce to forms with wp_nonce_field() and to links with wp_nonce_url()
  • verify it with check_admin_referer() in admin screens, check_ajax_referer() for AJAX, or wp_verify_nonce() elsewhere
  • use a specific action name per form, not one shared name for everything

A nonce is not a permission check. Use both.

3. Validate, then sanitise, every input

Validation answers: is this the kind of data I expect? An email address, a whole number in a range, one of a fixed list of options. If it fails, reject it with a clear error rather than trying to repair it.

Sanitisation cleans data that has passed validation so it is safe to store and process. WordPress provides functions for the common types:

DataTypical function
Single-line textsanitize_text_field()
Multi-line textsanitize_textarea_field()
Email addresssanitize_email()
Whole numberabsint() or intval()
Slug or keysanitize_key() or sanitize_title()
URL to storeesc_url_raw()
Limited HTMLwp_kses() or wp_kses_post()

For fixed options, the safest approach is an allow list: check the submitted value is one of the values you defined, and reject anything else.

4. Escape every output, as late as possible

Escaping converts data into a form that cannot break out of the context it is printed into. The function depends on the context:

ContextFunction
Text inside HTMLesc_html()
HTML attribute valueesc_attr()
URL in href or srcesc_url()
Inline JavaScript datawp_json_encode() or esc_js()
Text in a textareaesc_textarea()
Trusted limited HTMLwp_kses_post()

Escape at the moment of output, not earlier. Data escaped when it was saved can be changed in the database, or reused in a different context where that escaping is wrong. Translation functions have escaping variants, such as esc_html__(), which should be used for any translated string printed into a page.

Failing to escape is how cross-site scripting happens: a script saved in a form field runs in an administrator's browser when they view the entry.

5. Query the database safely

Never build SQL by joining strings with user input. Use $wpdb->prepare() with placeholders for every value in a custom query. Better still, use WordPress's own APIs, such as WP_Query, get_posts() and the metadata functions, which handle this for you.

For table and column names, which cannot be placeholders, use an allow list of known names.

6. Lock down REST API endpoints

Every endpoint registered with register_rest_route() needs a permission_callback. Returning true makes the endpoint public, which is correct only for data that is genuinely public. For anything else, check capabilities inside the callback.

Define the arguments each endpoint accepts with types, validation and sanitisation callbacks, so WordPress rejects malformed requests before your code runs.

7. Handle files with care

File uploads are a common route to a full site compromise.

  • use wp_handle_upload() or the media functions rather than moving files yourself
  • check file types against an allow list, using WordPress's file type checks rather than the extension the browser sent
  • never let user input decide a file path without strict validation
  • block direct access to plugin PHP files by checking that WordPress is loaded, the usual ABSPATH check at the top of each file

8. Keep secrets out of the code

API keys and credentials belong in configuration, such as constants in wp-config.php or environment variables, not in the plugin's source or in a public repository. If a plugin stores keys in the database, restrict who can view them in the admin and never print them back in full.

9. Fail safely and log sensibly

When something goes wrong, show the user a plain message and log the detail somewhere only administrators can see. Never print database errors, stack traces or API responses to visitors. Log enough to diagnose a problem, and never log passwords, card data or full personal records.

A review checklist for any plugin

  1. Does every action that changes data check a capability and a nonce?
  2. Is every input validated and sanitised with the right function for its type?
  3. Is every output escaped for its context, at the point of output?
  4. Do all custom queries use $wpdb->prepare() or WordPress query APIs?
  5. Does every REST route have a real permission callback?
  6. Are uploads restricted by type and handled by WordPress functions?
  7. Are plugin files protected from direct access?
  8. Are secrets stored outside the code?
  9. Does the plugin load only on the screens and pages that need it?
  10. Has it been run through PHP_CodeSniffer with the WordPress Coding Standards, or the official Plugin Check tool?

Automated tools catch a lot of this, but not missing permission checks or flawed logic. A human review by a second developer is the only reliable check for those.

Security and structure go together

Plugins that are well structured are easier to secure, because input handling, permissions and output each live in one predictable place. The structure I use is described in WordPress plugin development with object-oriented PHP. And security is part of the case for a small custom plugin over a stack of loosely connected ones, covered in when you need a custom WordPress plugin.

Next step

If you have a custom plugin and are not sure how it handles any of the above, I can review it. If you are commissioning one, these practices are standard on every project through the plugin development service. For the wider context of how plugins fit into a business site, see the WordPress website development guide.

Worked examples

Related services

Related reading

FAQ

Questions about this

If yours isn't here, send it over — I reply within one working day.

Sanitising cleans data on the way in, before it is stored or used, for example stripping tags from a text field. Escaping makes data safe on the way out, for the exact context it is printed into, such as HTML, an attribute or a URL. You need both. Sanitising does not make data safe to output, because the safe form depends on where it is printed.

No. A nonce confirms the request came from a form your site generated, which protects against cross-site request forgery. It does not confirm the user is allowed to perform the action. Every action that changes data needs a nonce check and a capability check, plus validation of the submitted values.

Ask the developer how input, output, permissions and database queries are handled, and ask for the code. A second developer can review a small plugin in a few hours. Automated tools such as PHP_CodeSniffer with the WordPress Coding Standards, and the official Plugin Check tool, catch many common problems, though not all of them.