Introduction: The Calm Before Release
Every plugin I build — free or commercial — goes through a quiet ritual before it sees the world. It’s tempting to hit “Release” after your last feature works, but that’s when subtle vulnerabilities like unescaped output, weak permissions, or forgotten debug files can undo months of work.
Security isn’t a checklist you do once; it’s a mindset woven into your build process. Still, I’ve developed a dependable workflow that keeps me honest before shipping.
Here’s what it looks like.
1. I Start with a Local Audit
Before touching tools, I manually read through my code like a stranger would. I ask:
-
What happens if someone sends unexpected data here?
-
Is this form submission protected?
-
Does this file need to be publicly accessible?
I check for:
-
Any
$_POST,$_GET,$_REQUEST, or$_SERVERusage without sanitization. -
Direct file access using
ABSPATHchecks:
if ( ! defined( 'ABSPATH' ) ) exit;
-
Database queries that skip
$wpdb->prepare().
Manual review slows me down, but it catches the human logic holes that scanners miss.
2. Sanitize and Escape Everything
WordPress gives you the tools — you just need to use them consistently.
-
Sanitize input:
$email = sanitize_email( $_POST['email'] ); $name = sanitize_text_field( $_POST['name'] );
- Escape output:
echo esc_html( $user_name ); echo esc_url( $profile_link );
- Validate intent:
Always pair user actions with nonces and capability checks:
check_admin_referer( 'save_plugin_settings' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Unauthorized action.' ); }
Sanitization is about trust boundaries — anything coming from users or APIs gets filtered before entering your logic.
3. Tighten File Access and Permissions
I make sure every PHP file starts with:
if ( ! defined( 'ABSPATH' ) ) exit;
Then I review my folders:
-
/includes→ executable code only -
/assets→ images, JS, CSS (no PHP) -
/admin/partials→ protected by capability checks
If deploying to a server, I ensure:
-
755for directories -
644for files -
No
.env,debug.log, orcomposer.jsonexposed publicly
4. Secure AJAX and REST API Endpoints
AJAX is one of the easiest attack vectors if left unguarded. My golden rules:
-
Prefix all actions:
add_action( 'wp_ajax_myplugin_save_data', 'myplugin_save_data' );
-
Require nonce + capability inside callbacks.
-
Never trust client data — sanitize everything again server-side.
For REST APIs:
-
Use
permission_callbackproperly. -
Don’t expose sensitive fields (like API keys) in responses.
5. Remove Debug and Test Code
A forgotten var_dump() can leak structure, paths, or credentials.
Before packaging, I:
-
Disable
WP_DEBUGand logging. -
Remove any test files (
test.php,dev.php). -
Search for patterns like
print_r(ordie(and remove them.
Sometimes, I’ll even grep my entire codebase:
grep -R "var_dump" .
6. Use Automated Scanning Tools
Manual checks are great, but automation backs me up.
-
WordPress Coding Standards (PHPCS):
Ensures consistent sanitization, escaping, and naming.
phpcs --standard=WordPress .
-
WPScan:
Scans for known vulnerabilities in dependencies and WordPress core. -
Dependabot / Composer Audit:
Keeps third-party libraries up to date.
Automation ensures you don’t forget the boring parts.
7. Check Capabilities, Not Roles
Instead of checking roles like:
if ( current_user_can( 'administrator' ) )
I always check specific capabilities:
if ( current_user_can( 'manage_options' ) )
This allows flexibility if roles change or new roles are added.
It’s a subtle difference, but it prevents privilege creep when plugins evolve.
8. Protect Secrets and API Keys
No API key belongs in your plugin’s PHP files.
I use:
-
wp-config.phpconstants for sensitive values. -
Encrypted storage or
get_option()with proper user capability checks.
For distribution, I strip any environment credentials before packaging:
find . -type f -name "*.env" -delete
9. Validate Outputs and Permissions on Frontend
Even frontend scripts can be abused.
I always:
-
Localize data with
wp_localize_script()instead of printing raw PHP. -
Add nonces to any frontend AJAX calls.
-
Use strict REST permissions for GET vs POST requests.
Example:
wp_localize_script( 'plugin-js', 'myplugin', [
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_nonce' )
]);
10. Final Test: Simulate a Curious User
Once everything checks out, I act like a user with too much curiosity.
-
I try sending malformed requests.
-
I inspect the console for exposed data.
-
I switch user roles and hit every endpoint I can find.
If nothing breaks and no sensitive info leaks, then — and only then — the plugin earns its release tag.
Closing Thoughts
Security is never “done.” It’s a muscle you train.
Every plugin launch is a chance to refine how you think about data, permissions, and trust. The more you automate your safety nets — and the more you slow down to review what you’ve built — the less you’ll worry about that 2 AM email saying your plugin was compromised.
A secure plugin isn’t just safer for users — it’s quieter for your mind.