Skip to main content
WP HealthKit

WordPress Security Headers: Complete Implementation Guide

May 2, 202616 min readSecurityBy Jamie

Table of Contents

  1. Understanding WordPress Security Headers
  2. X-Frame-Options and Clickjacking Prevention
  3. HSTS Headers and HTTPS Enforcement
  4. Additional Critical Security Headers
  5. Implementing Headers in WordPress Plugins
  6. Testing and Validation Methods
  7. Common Implementation Mistakes
  8. Securing Your Plugin Code

Understanding WordPress Security Headers

WordPress security headers are HTTP response headers that instruct browsers how to handle your site's content and protect against specific attack vectors. While most developers focus on WordPress code security, HTTP headers provide a crucial second layer of defense that operates outside your application code.

HTTP security headers are particularly important for WordPress sites because they protect against entire categories of attacks—clickjacking, MIME sniffing, SSL stripping, and cross-site framing—without requiring code changes to your theme or plugins. When a plugin sets these headers correctly, they apply to every page of your WordPress site automatically.

The challenge with WordPress security headers implementation is that each header solves a specific problem, uses different syntax, and has different browser support levels. Implementing them incorrectly creates confusion rather than protection. Additionally, conflicting headers from multiple plugins can disable each other's protections.

WP HealthKit's security auditing checks that your WordPress security headers are implemented correctly, that they don't conflict with each other, and that they actually protect against the threats they're designed for. The audit specifically looks for missing critical headers and misconfigured values that provide false security.

X-Frame-Options and Clickjacking Prevention

The X-Frame-Options header prevents clickjacking attacks by controlling whether your WordPress site can be embedded in frames on other sites. Clickjacking occurs when attackers place your site in an invisible frame on their malicious site, tricking users into clicking buttons that perform unintended actions.

The three X-Frame-Options values are:

DENY prevents any site from framing your WordPress content:

function wp_healthkit_add_frame_options_deny() {
    header( 'X-Frame-Options: DENY' );
}

add_action( 'send_headers', 'wp_healthkit_add_frame_options_deny' );

DENY is the most restrictive but works for most WordPress sites. If you use embedded content like YouTube videos, this is still safe—the header prevents external sites from framing your WordPress site, but doesn't affect your ability to embed content.

SAMEORIGIN allows framing only from the same domain:

function wp_healthkit_add_frame_options_same_origin() {
    header( 'X-Frame-Options: SAMEORIGIN' );
}

add_action( 'send_headers', 'wp_healthkit_add_frame_options_same_origin' );

Use SAMEORIGIN if you need to frame your own pages (like within a multisite setup or when using iframes within your admin panel). This prevents external framing while allowing internal use.

ALLOW-FROM specifies specific domains that can frame your content:

function wp_healthkit_add_frame_options_specific() {
    // Note: ALLOW-FROM is deprecated, use CSP frame-ancestors instead
    header( 'X-Frame-Options: ALLOW-FROM https://trusted.example.com' );
}

add_action( 'send_headers', 'wp_healthkit_add_frame_options_specific' );

ALLOW-FROM is deprecated in favor of Content Security Policy, so implement this sparingly.

The critical mistake most developers make is not implementing X-Frame-Options at all. Without this header, every WordPress site is vulnerable to clickjacking:

// Proper implementation with conditional logic
function wp_healthkit_set_frame_options() {
    // Don't set headers if already set (another plugin may have)
    if ( ! headers_sent() && ! isset( $_SERVER['HTTP_X_FRAME_OPTIONS'] ) ) {
        // Use most restrictive default
        $frame_options = apply_filters( 'wp_healthkit_x_frame_options', 'DENY' );
        header( 'X-Frame-Options: ' . $frame_options );
    }
}

// Run on all responses
add_action( 'send_headers', 'wp_healthkit_set_frame_options', 0 );

Notice the apply_filters() call—this allows other plugins to customize the header value without creating conflicts. This is the WordPress way of handling headers set by multiple sources.

HSTS Headers and HTTPS Enforcement

HSTS (HTTP Strict-Transport-Security) headers force browsers to use HTTPS for all future connections to your WordPress site. This protects against SSL stripping attacks where attackers intercept HTTP traffic and prevent the upgrade to HTTPS.

Here's the critical requirement: only set HSTS if your site has a valid HTTPS certificate. Setting HSTS on an HTTP site will break your site for browsers that have seen the header before.

The basic HSTS implementation:

function wp_healthkit_add_hsts_header() {
    // Only set HSTS on HTTPS sites
    if ( is_ssl() ) {
        $max_age = 31536000; // 1 year in seconds
        header( 'Strict-Transport-Security: max-age=' . $max_age . '; includeSubDomains' );
    }
}

add_action( 'send_headers', 'wp_healthkit_add_hsts_header' );

Breaking this down:

  • max-age=31536000: Tells browsers to remember this setting for 1 year. Increasing this value provides better protection but takes longer to fix if you make mistakes.
  • includeSubDomains: Applies HSTS to all subdomains (api.example.com, cdn.example.com, etc.). Only use this if you control all subdomains.

HSTS Preload takes protection a step further:

function wp_healthkit_add_hsts_preload() {
    if ( is_ssl() ) {
        $max_age = 63072000; // 2 years - required for preload
        header( 'Strict-Transport-Security: max-age=' . $max_age . '; includeSubDomains; preload' );
    }
}

add_action( 'send_headers', 'wp_healthkit_add_hsts_preload' );

With preload, you can submit your domain to the HSTS preload list (https://hstspreload.org), and browsers will come with your domain pre-configured to always use HTTPS, even on first visit.

However, preload is a serious commitment—it takes months to remove your site from the preload list. Only enable preload if you're certain you'll maintain HTTPS indefinitely.

Practical HSTS Implementation with Gradual Enforcement:

function wp_healthkit_safe_hsts() {
    if ( ! is_ssl() ) {
        return;
    }
    
    $max_age = (int) get_option( 'wp_healthkit_hsts_max_age', 3600 ); // Start with 1 hour
    
    // Allow administrators to increase max-age gradually
    // Week 1: 3600 (1 hour)
    // Week 2: 86400 (1 day)
    // Week 3: 604800 (1 week)
    // Week 4: 31536000 (1 year)
    
    $include_subdomains = get_option( 'wp_healthkit_hsts_subdomains', false ) ? '; includeSubDomains' : '';
    $preload = get_option( 'wp_healthkit_hsts_preload', false ) ? '; preload' : '';
    
    header( 'Strict-Transport-Security: max-age=' . $max_age . $include_subdomains . $preload );
}

add_action( 'send_headers', 'wp_healthkit_safe_hsts', 0 );

This approach lets administrators safely test HSTS with short initial periods before committing to longer durations.


Mid-Article CTA

Is your WordPress plugin sending the right security headers? WP HealthKit automatically analyzes your plugin code and server configuration to verify security headers are properly implemented and don't conflict. Upload your plugin to WP HealthKit to receive a detailed security headers audit with remediation guidance.


Additional Critical Security Headers

Beyond X-Frame-Options and HSTS, several other WordPress security headers deserve attention:

X-Content-Type-Options: nosniff

This header prevents MIME type sniffing, where browsers try to determine file types by analyzing content rather than respecting Content-Type headers. An attacker could upload a file with a .txt extension that contains JavaScript, and the browser might execute it.

function wp_healthkit_prevent_mime_sniffing() {
    header( 'X-Content-Type-Options: nosniff' );
}

add_action( 'send_headers', 'wp_healthkit_prevent_mime_sniffing' );

This header is almost universally safe and should be on every WordPress site.

Referrer-Policy

Controls how much information browsers send in the Referrer header when following links. Referrer headers can leak sensitive information from your site's URLs to external sites:

function wp_healthkit_set_referrer_policy() {
    // strict-no-referrer: Don't send referrer to anyone
    // no-referrer-when-downgrade: Don't send when going to HTTP
    // strict-no-referrer-when-downgrade: Strict no-referrer only on downgrade
    // same-origin: Only send referrer to same domain
    // origin: Send only the origin
    header( 'Referrer-Policy: strict-no-referrer-when-downgrade' );
}

add_action( 'send_headers', 'wp_healthkit_set_referrer_policy' );

Permissions-Policy (formerly Feature-Policy)

Controls which browser features your site can use—camera, microphone, geolocation, payment request API:

function wp_healthkit_set_permissions_policy() {
    $policy = 'geolocation=(), microphone=(), camera=(), payment=()';
    header( 'Permissions-Policy: ' . $policy );
}

add_action( 'send_headers', 'wp_healthkit_set_permissions_policy' );

Most WordPress sites don't use these features, so explicitly disabling them reduces attack surface.

Implementing Headers in WordPress Plugins

The best practice for WordPress plugin developers is to provide configuration options for security headers rather than forcing specific values. Different sites have different requirements, and a one-size-fits-all approach creates problems.

Here's a production-ready security headers plugin pattern:

<?php
/**
 * Plugin Name: WP HealthKit Security Headers
 * Description: Configurable security headers for WordPress
 * Version: 1.0.0
 */

// Avoid direct access
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class WP_HealthKit_Security_Headers {
    private $headers = array();
    
    public function __construct() {
        // Set defaults
        $this->headers = array(
            'X-Frame-Options' => 'DENY',
            'X-Content-Type-Options' => 'nosniff',
            'Referrer-Policy' => 'strict-no-referrer-when-downgrade',
        );
        
        // Add HSTS only on HTTPS
        if ( is_ssl() ) {
            $this->headers['Strict-Transport-Security'] = 
                'max-age=31536000; includeSubDomains';
        }
        
        // Allow filtering before sending
        $this->headers = apply_filters( 'wp_healthkit_security_headers', $this->headers );
        
        // Send headers
        add_action( 'send_headers', array( $this, 'send_headers' ), 0 );
        
        // Add admin page
        add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
        add_action( 'admin_init', array( $this, 'register_settings' ) );
    }
    
    public function send_headers() {
        foreach ( $this->headers as $header => $value ) {
            if ( ! headers_sent() ) {
                header( $header . ': ' . $value );
            }
        }
    }
    
    public function add_admin_menu() {
        add_options_page(
            'Security Headers',
            'Security Headers',
            'manage_options',
            'wp-healthkit-headers',
            array( $this, 'admin_page' )
        );
    }
    
    public function register_settings() {
        register_setting( 'wp_healthkit_headers', 'wp_healthkit_x_frame_options' );
        register_setting( 'wp_healthkit_headers', 'wp_healthkit_hsts_enabled' );
    }
    
    public function admin_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( 'Unauthorized' );
        }
        
        ?>
        <div class="wrap">
            <h1>WordPress Security Headers Configuration</h1>
            <form method="post" action="options.php">
                <?php settings_fields( 'wp_healthkit_headers' ); ?>
                <table class="form-table">
                    <tr>
                        <th scope="row">
                            <label for="x_frame_options">X-Frame-Options</label>
                        </th>
                        <td>
                            <select name="wp_healthkit_x_frame_options" id="x_frame_options">
                                <option value="DENY">DENY (Most Restrictive)</option>
                                <option value="SAMEORIGIN">SAMEORIGIN</option>
                            </select>
                            <p class="description">Prevents clickjacking attacks</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row">
                            <label for="hsts">Enable HSTS</label>
                        </th>
                        <td>
                            <input type="checkbox" 
                                   name="wp_healthkit_hsts_enabled" 
                                   id="hsts" 
                                   <?php checked( get_option( 'wp_healthkit_hsts_enabled' ) ); ?> />
                            <p class="description">Enforce HTTPS for all future connections (requires valid SSL)</p>
                        </td>
                    </tr>
                </table>
                <?php submit_button(); ?>
            </form>
        </div>
        <?php
    }
}

// Initialize plugin
new WP_HealthKit_Security_Headers();

Testing and Validation Methods

Testing security headers requires checking that they're actually being sent by your server. Here are multiple verification approaches:

Browser DevTools Method:

Open the Network tab in Chrome DevTools, reload the page, click on the HTML document request, and scroll to the Response Headers section. You should see your security headers listed:

X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-no-referrer-when-downgrade

Command Line Testing with curl:

# Check security headers using curl
curl -I https://example.com | grep -i "X-Frame-Options\|X-Content-Type\|Strict-Transport"

# Output:
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Strict-Transport-Security: max-age=31536000; includeSubDomains

Online Security Header Scanners:

Several free online tools test security headers:

# Mozilla Observatory (https://observatory.mozilla.org)
# Securityheaders.com
# SSL Labs (ssllabs.com) - includes header testing

Automated Testing with WordPress:

function test_security_headers() {
    $response = wp_remote_get( home_url() );
    
    if ( is_wp_error( $response ) ) {
        return;
    }
    
    $headers = wp_remote_retrieve_headers( $response );
    
    // Test X-Frame-Options
    if ( ! isset( $headers['X-Frame-Options'] ) ) {
        echo 'Warning: X-Frame-Options header missing' . PHP_EOL;
    }
    
    // Test HSTS on HTTPS sites
    if ( is_ssl() && ! isset( $headers['Strict-Transport-Security'] ) ) {
        echo 'Warning: HSTS header missing on HTTPS site' . PHP_EOL;
    }
    
    // Test X-Content-Type-Options
    if ( ! isset( $headers['X-Content-Type-Options'] ) ) {
        echo 'Warning: X-Content-Type-Options header missing' . PHP_EOL;
    }
}

// Run test
add_action( 'admin_init', 'test_security_headers' );

Programmatic Testing:

// Test in browser console
function testSecurityHeaders() {
    const testWindow = window.open('about:blank', 'test');
    
    fetch(window.location.href)
        .then(response => {
            console.log('Security Headers:');
            console.log('X-Frame-Options:', response.headers.get('X-Frame-Options'));
            console.log('X-Content-Type-Options:', response.headers.get('X-Content-Type-Options'));
            console.log('HSTS:', response.headers.get('Strict-Transport-Security'));
        });
}

testSecurityHeaders();

Common Implementation Mistakes

Mistake 1: Setting Headers After Headers Already Sent

Headers must be sent before any output. A common error is including whitespace before opening <?php tags:

// WRONG - whitespace causes headers to send
 <?php
function wp_healthkit_set_headers() {
    header( 'X-Frame-Options: DENY' ); // Too late - headers already sent!
}

// RIGHT - no whitespace
<?php
function wp_healthkit_set_headers() {
    header( 'X-Frame-Options: DENY' );
}

Mistake 2: Conflicting Headers from Multiple Plugins

If two plugins set X-Frame-Options with different values, the last one loaded wins. This can create unexpected behavior:

// Plugin A
add_action( 'send_headers', function() {
    header( 'X-Frame-Options: SAMEORIGIN' );
});

// Plugin B (loaded after A)
add_action( 'send_headers', function() {
    header( 'X-Frame-Options: DENY' ); // Overwrites Plugin A
});

Solution: Use apply_filters() to allow cooperation:

function wp_healthkit_send_frame_options() {
    $frame_options = apply_filters( 'wp_healthkit_x_frame_options', 'DENY' );
    header( 'X-Frame-Options: ' . $frame_options );
}

add_action( 'send_headers', 'wp_healthkit_send_frame_options', 99 );

Mistake 3: HSTS on Non-HTTPS Sites

Setting HSTS on HTTP breaks your site:

// WRONG
function wp_healthkit_add_hsts() {
    header( 'Strict-Transport-Security: max-age=31536000' );
}

// RIGHT
function wp_healthkit_add_hsts() {
    if ( is_ssl() ) {
        header( 'Strict-Transport-Security: max-age=31536000' );
    }
}

Mistake 4: Using ALLOW-FROM Instead of CSP

X-Frame-Options: ALLOW-FROM is deprecated. Use Content-Security-Policy instead:

// Deprecated
header( 'X-Frame-Options: ALLOW-FROM https://trusted.com' );

// Modern approach
header( "Content-Security-Policy: frame-ancestors 'self' https://trusted.com" );

Securing Your Plugin Code

Security headers protect against specific attacks, but your plugin code needs protection too. WP HealthKit's audit goes beyond headers to examine:

  • Use of proper nonce verification
  • Escaping and sanitization of outputs
  • Input validation
  • Database query safety
  • Proper use of WordPress security functions

When you implement security headers in your plugin, also ensure you're following WordPress plugin security guidelines:

  1. Verify nonces for form submissions
  2. Escape all output with esc_html(), esc_url(), wp_kses_post()
  3. Sanitize all input with sanitize_text_field(), sanitize_email()
  4. Use $wpdb->prepare() for database queries
  5. Never trust user input

The combination of properly implemented security headers and secure plugin code creates a defense-in-depth approach that protects against most common WordPress attacks.

Additional Resources

For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 17 verification layers or browse the plugin directory to see real audit scores. Ready to check your own plugin? Run a free audit now.

Broader Context and Best Practices

Security vulnerabilities in WordPress plugins don't exist in isolation. Each vulnerability represents a potential entry point that attackers chain together to achieve broader compromise. A seemingly minor issue like improper input validation can escalate when combined with a privilege escalation flaw, turning a low-severity finding into a critical breach. This interconnected nature of security weaknesses is why comprehensive auditing matters so much. Rather than checking individual items in isolation, modern security analysis examines how different components interact and where those interactions create unexpected attack surfaces that manual review would miss entirely.

The WordPress plugin ecosystem's open-source nature creates both strengths and challenges for security. Open code allows community review, which catches many issues early. However, it also means attackers can study source code to find exploitable patterns before patches are released. This asymmetry makes proactive security testing essential rather than reactive. Developers who integrate automated security scanning into their development workflow catch vulnerabilities during development, long before code reaches production. The cost of fixing a security issue during development is orders of magnitude lower than addressing it after a public disclosure or active exploitation.

Understanding the attacker's perspective transforms how developers approach security. Attackers don't think in terms of individual functions or classes. They think in terms of data flows, trust boundaries, and privilege transitions. When data crosses from an untrusted context like user input into a trusted context like a database query, that boundary is where vulnerabilities emerge. By mapping these trust boundaries in your plugin architecture, you can systematically identify where validation, sanitization, and authorization checks are needed. This threat modeling approach is far more effective than trying to remember individual security rules for every function call.

WordPress powers over forty percent of the web, making it the single largest target for automated attacks. Plugin vulnerabilities are the primary vector for these attacks, with Patchstack reporting thousands of new plugin vulnerabilities each year. The scale of the WordPress ecosystem means that even a vulnerability affecting a relatively obscure plugin can impact hundreds of thousands of sites. This reality underscores why every plugin developer has a responsibility to take security seriously, regardless of their plugin's install base. Automated security testing with tools like WP HealthKit makes this responsibility manageable.

Frequently Asked Questions

Should I set security headers at the WordPress level or in my .htaccess / server config?

Both approaches work, but WordPress-level headers (in your plugin) offer advantages: they're easier to manage for multisite setups, they travel with your WordPress installation if you migrate servers, and they can be configured per-plugin. Server-level headers are slightly more efficient since they apply to all requests including static files. For most WordPress sites, implementing at the plugin level is cleaner.

What if I need to allow external sites to frame my content?

Use Content-Security-Policy's frame-ancestors directive instead of X-Frame-Options. CSP is more flexible and allows specifying exactly which domains can frame your content:

header( "Content-Security-Policy: frame-ancestors 'self' https://partner.com" );

Can security headers break my site?

Yes, if misconfigured. Common issues: HSTS on HTTP sites, overly restrictive CSP that blocks your own resources, or X-Frame-Options blocking content you actually need. Always test in a staging environment before enabling headers on production.

How do I choose max-age values for HSTS?

Start with 3600 (1 hour) while testing, then increase to 86400 (1 day), 604800 (1 week), and eventually 31536000 (1 year). Once you go above 1 year, you should consider submitting for HSTS preload. Don't jump straight to max values without testing.

Do security headers protect against all attacks?

No. Security headers are one layer of defense. They protect against specific attack types (clickjacking, MIME sniffing, SSL stripping) but don't protect against SQL injection, XSS through your application code, or authentication bypass. You need comprehensive security including secure coding practices, which WP HealthKit audits thoroughly.

Will security headers affect my site's performance?

No. Security headers are sent as HTTP response metadata and don't affect page load time or functionality. They're pure security benefits with no performance trade-off.

Conclusion

WordPress security headers provide critical protection against clickjacking, MIME sniffing, SSL stripping, and other attack vectors. Implementing them correctly means understanding each header's purpose, testing thoroughly, and avoiding conflicts between multiple plugins.

The implementation patterns shared here—conditional HSTS checking, filter-based header configuration, gradual HSTS enforcement—represent production-ready approaches that balance security with safety.

However, implementation details matter enormously. A misconfigured header provides false confidence while potentially breaking functionality. WP HealthKit automatically verifies your WordPress security headers are correct, don't conflict with other plugins, and actually protect against the threats they're designed for.

Upload your plugin to WP HealthKit to receive a detailed security headers audit, identify missing critical headers, and get specific remediation guidance for each issue found.

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 49 verification layers.

Comments

WordPress Security Headers: Complete Implementation Guide | WP HealthKit