WordPress Security Best Practices for Developers
WordPress powers over 40% of the web, which makes it a prime target for attackers. Here are the security practices I follow to keep WordPress sites secure.
TL;DR
Always escape output, use prepared statements for database queries, validate nonces for forms, keep everything updated, and avoid bloated page builders when possible.
Basic Security Practices
1. Keep Everything Updated
Update WordPress core, themes, and plugins weekly - or even daily if you’re managing critical sites.
Why this matters: Most WordPress hacks exploit known vulnerabilities in outdated software. Security patches are released regularly, and hackers scan for sites running vulnerable versions.
How to automate:
// Enable automatic updates for minor releases (recommended)
add_filter( 'allow_minor_auto_core_updates', '__return_true' );
// Enable automatic updates for plugins (use with caution)
add_filter( 'auto_update_plugin', '__return_true' );
Important: Remove unused themes and plugins entirely. Deactivated plugins can still be exploited.
2. Use Strong Passwords
Use a password manager like 1Password, Bitwarden, or LastPass. Generate unique, complex passwords for:
- WordPress admin accounts
- Database users
- FTP/SFTP accounts
- Hosting control panels
Never use:
- “admin” as a username
- Common passwords like “password123”
- The same password across multiple sites
3. Avoid Bloated Page Builders
Popular page builders like Elementor, Divi, and WPBakery add thousands of lines of code and increase your attack surface. I’ve personally seen sites compromised through vulnerabilities in these plugins.
Better alternatives:
- Use Gutenberg (WordPress’s native block editor)
- Build custom block themes
- Use ACF (Advanced Custom Fields) for flexible content
If you must use a page builder, keep it religiously updated and minimize the number of add-ons.
Development Security Practices
4. Escape All Output
Rule: Never trust any data, even from your own database. Always escape output based on context.
Common escaping functions:
// For HTML output
echo esc_html( $title );
// For HTML attributes
<input type="text" value="<?php echo esc_attr( $value ); ?>" />
// For URLs
<a href="<?php echo esc_url( $link ); ?>">Link</a>
// For JavaScript strings
<script>
var message = "<?php echo esc_js( $message ); ?>";
</script>
// For translations with HTML
echo wp_kses_post( $content );
// For text areas (preserves line breaks)
<textarea><?php echo esc_textarea( $text ); ?></textarea>
Why this matters: Prevents XSS (Cross-Site Scripting) attacks where malicious JavaScript gets injected into your pages.
Bad example:
// VULNERABLE - Never do this!
<div class="user-comment">
<?php echo $comment; ?>
</div>
Good example:
<div class="user-comment">
<?php echo esc_html( $comment ); ?>
</div>
5. Use WordPress Functions
WordPress has built-in functions for almost everything. Use them instead of reinventing the wheel.
Examples:
// Get current URL - Use WordPress function
$current_url = home_url( add_query_arg( array(), $wp->request ) );
// Instead of: $_SERVER['REQUEST_URI']
// Get post data - Use WordPress function
$post = get_post( $post_id );
// Instead of: $wpdb->get_row("SELECT * FROM wp_posts...")
// Check user capabilities
if ( current_user_can( 'edit_posts' ) ) {
// Allow editing
}
// Instead of checking roles directly
Why: WordPress functions handle security, caching, and edge cases you might miss.
6. Always Use $wpdb for Database Queries
Never write raw SQL queries. Use WordPress’s database abstraction layer.
global $wpdb;
// Get results
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}posts WHERE post_status = %s AND post_type = %s",
'publish',
'post'
)
);
// Get a single variable
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_author = %d",
$author_id
)
);
// Insert data
$wpdb->insert(
$wpdb->prefix . 'my_table',
array(
'column1' => $value1,
'column2' => $value2
),
array( '%s', '%d' ) // Data format
);
7. Use $wpdb->prepare() to Prevent SQL Injection
Critical: Always use prepared statements for dynamic SQL queries.
Placeholders:
%s- String%d- Integer%f- Float
Bad example - SQL Injection vulnerability:
// VULNERABLE! Never do this!
$user_id = $_GET['user_id'];
$query = "SELECT * FROM {$wpdb->prefix}users WHERE ID = $user_id";
$user = $wpdb->get_row( $query );
An attacker could send ?user_id=1 OR 1=1 to dump all users.
Good example:
$user_id = absint( $_GET['user_id'] ); // Sanitize input
$user = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}users WHERE ID = %d",
$user_id
)
);
8. Use Nonces to Protect Forms
Nonces (Numbers Used Once) prevent CSRF (Cross-Site Request Forgery) attacks.
Creating a nonce:
// In your form
<form method="post">
<?php wp_nonce_field( 'my_action_name', 'my_nonce_field' ); ?>
<input type="text" name="user_input" />
<button type="submit">Submit</button>
</form>
Verifying a nonce:
// In your form handler
if ( isset( $_POST['my_nonce_field'] ) ) {
// Verify nonce
if ( ! wp_verify_nonce( $_POST['my_nonce_field'], 'my_action_name' ) ) {
wp_die( 'Security check failed' );
}
// Process form
$user_input = sanitize_text_field( $_POST['user_input'] );
// ... rest of processing
}
For AJAX requests:
// In your JavaScript
jQuery.post(
ajaxurl,
{
action: 'my_ajax_action',
nonce: '<?php echo wp_create_nonce( "my_ajax_nonce" ); ?>',
data: formData
},
function(response) {
console.log(response);
}
);
// In your PHP AJAX handler
add_action( 'wp_ajax_my_ajax_action', 'handle_my_ajax' );
function handle_my_ajax() {
check_ajax_referer( 'my_ajax_nonce', 'nonce' );
// Process request
wp_send_json_success( $data );
}
Additional Security Measures
Validate and Sanitize Input
Always sanitize user input:
// Text fields
$clean_text = sanitize_text_field( $_POST['text'] );
// Email
$clean_email = sanitize_email( $_POST['email'] );
// URL
$clean_url = esc_url_raw( $_POST['url'] );
// HTML content (allows safe HTML)
$clean_html = wp_kses_post( $_POST['content'] );
// Integer
$clean_int = absint( $_POST['number'] );
Limit Login Attempts
Install a plugin like “Limit Login Attempts Reloaded” to prevent brute force attacks, or implement your own:
// Track failed login attempts
add_action( 'wp_login_failed', 'track_failed_login' );
function track_failed_login( $username ) {
$attempts = get_transient( 'failed_login_' . $username );
$attempts = $attempts ? $attempts + 1 : 1;
set_transient( 'failed_login_' . $username, $attempts, 3600 ); // 1 hour
if ( $attempts >= 5 ) {
// Lock out user
set_transient( 'locked_out_' . $username, true, 3600 );
}
}
Use HTTPS
Always use SSL/TLS certificates. Most hosts offer free Let’s Encrypt certificates.
// Force HTTPS in wp-config.php
define( 'FORCE_SSL_ADMIN', true );
Security Checklist
- WordPress core, themes, and plugins are up to date
- Unused themes and plugins removed
- Strong passwords on all accounts
- All output is escaped with proper functions
- Database queries use
$wpdb->prepare() - Forms protected with nonces
- User input is sanitized
- File upload validation implemented
- HTTPS enabled site-wide
- Regular backups configured
- Security plugin installed (WordFence, Sucuri, etc.)
- Debug mode disabled in production
Helpful Resources
- WordPress Security Handbook - Your bible for WordPress security
- Theme Security Guide - Essential for theme developers
- Plugin Security Handbook - For plugin developers
- Data Validation - Comprehensive guide to sanitization
Key Takeaways
- Security is a layered approach - no single measure is enough
- Escape output, sanitize input, validate everything
- Use WordPress’s built-in functions - they’re battle-tested
- Prepared statements prevent SQL injection
- Nonces prevent CSRF attacks
- Keep everything updated and remove unused code
- When in doubt, check the WordPress documentation