Skip to main content
WP HealthKit

WordPress Content Security Policy: A Complete Plugin Guide

April 27, 202616 min readSecurityBy Jamie

Table of Contents

Introduction

WordPress powers over 40% of the web, making it a prime target for attackers. One of the most sophisticated attacks targeting WordPress installations is Cross-Site Scripting (XSS), where malicious JavaScript is injected into pages to steal user data, create backdoors, or redirect visitors to harmful sites. While server-side escaping and input sanitization are crucial defenses, they're not sufficient on their own.

This is where WordPress Content Security Policy (CSP) headers come into play. CSP is a powerful browser security mechanism that acts as a second line of defense against injection attacks. Unlike traditional firewalls or intrusion detection systems, CSP works directly in the browser, restricting what resources can be loaded and what code can execute on your pages.

Implementing WordPress Content Security Policy properly in your plugins can dramatically reduce your attack surface and protect your users. Yet many WordPress developers avoid CSP because it seems complicated, breaks functionality, or requires extensive refactoring. The truth is that modern WordPress—especially when building custom plugins—makes CSP implementation more accessible than ever.

What is Content Security Policy and Why WordPress Needs It

Content Security Policy is an HTTP response header that tells the browser exactly which resources are allowed to load and execute on a page. Instead of blindly trusting every resource, the browser enforces these rules and blocks anything that violates the policy.

Content Security Policy operates at a different level than traditional application security controls. Rather than trying to prevent injection vulnerabilities through input validation and output escaping—which are still essential—CSP prevents injection attacks from having impact even if they successfully inject code. This defense-in-depth approach transforms CSP from a convenience into a necessity for high-security applications.

CSP Headers take effect immediately when set, allowing you to prevent XSS attacks without requiring changes to your application code. You could have a plugin with a small XSS vulnerability that you're working on fixing. While you're developing the patch, CSP can prevent the vulnerability from being exploited by blocking inline scripts or external resources. This isn't a replacement for fixing the vulnerability, but it's a powerful interim protection mechanism.

A basic CSP header looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com; style-src 'self' 'unsafe-inline'

This header tells the browser: "By default, only load resources from my own domain. For scripts, I allow my domain and cdn.example.com. For styles, I allow my domain and inline styles."

Why does WordPress need this? WordPress sites aggregate content from many sources: plugins, themes, external CDNs, shortcodes, and user-generated content. Each of these introduces potential attack vectors. A compromised plugin could inject malicious JavaScript. A reflected XSS vulnerability in your theme could execute arbitrary code. Even well-intentioned plugins sometimes pull in third-party libraries without proper security review.

CSP doesn't prevent these vulnerabilities from existing, but it prevents them from being exploited. If a plugin has an XSS vulnerability but your CSP policy blocks inline scripts, the malicious code can't execute. The attacker can inject the payload, but the browser's CSP enforcement stops it cold.

This defense-in-depth approach is essential in the WordPress ecosystem. While developers should still implement proper input validation, output escaping, and vulnerability fixes, CSP provides an additional safety net. CSP enforcement happens at the browser level, where the attacker cannot circumvent it. Even if you miss an escaping opportunity, CSP catches the attempt at the browser boundary. This layered approach transforms WordPress from a system vulnerable to any unpatched XSS into a system resilient to injection attacks across multiple vectors.

The computational cost of CSP enforcement is negligible—modern browsers evaluate CSP in microseconds. The administrative cost, however, can be significant during initial implementation, particularly for sites with extensive plugin ecosystems and complex resource loading patterns. However, once properly configured, CSP requires minimal ongoing maintenance while providing continuous protection against evolving attack techniques.

WordPress Content Security Policy is particularly important because:

  • Plugin ecosystem risk: WordPress plugins vary widely in security maturity
  • User permissions: WordPress administrators might install untrusted plugins
  • Custom integrations: Sites often integrate with multiple external services
  • Legacy code: Older themes and plugins use unsafe patterns like inline script tags

WP HealthKit helps you audit your plugins against CSP requirements, ensuring your security posture is actually protecting users.

Understanding CSP Directives and WordPress Context

CSP uses directives to control different types of resources and behaviors. Here are the most important directives for WordPress development:

Script-src

Controls which resources can execute as JavaScript. This is the most critical directive because JavaScript is how most attacks are executed.

script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.example.com
  • 'self': Allow scripts from your own domain
  • 'unsafe-inline': Allow inline script tags (major security weakness)
  • 'unsafe-eval': Allow eval() and similar functions (major security weakness)
  • https://cdn.example.com: Allow scripts from specific domains

Style-src

Controls stylesheet loading:

style-src 'self' 'unsafe-inline' https://fonts.googleapis.com

Many WordPress themes use inline styles, making 'unsafe-inline' necessary (though not ideal).

Img-src and Media-src

Control images and media loading. Usually permissive since images are lower risk:

img-src 'self' data: https: blob:;
media-src 'self' https://cdn.example.com

Connect-src

Restricts where your page can make requests (XHR, WebSocket, etc.):

connect-src 'self' https://api.example.com wss://socket.example.com

Frame-src and Form-action

frame-src controls which pages can be embedded in iframes. form-action controls where forms can submit:

frame-src 'self' https://www.youtube.com;
form-action 'self' https://payment.example.com

Base-uri and Object-src

base-uri restricts the base URL for relative URLs. object-src controls Flash and other plugins:

base-uri 'self';
object-src 'none'

A well-constructed WordPress CSP policy typically looks like:

default-src 'self'; 
script-src 'self' 'unsafe-inline' cdn.example.com; 
style-src 'self' 'unsafe-inline' fonts.googleapis.com; 
img-src 'self' data: https:; 
font-src 'self' fonts.gstatic.com; 
connect-src 'self' https://api.example.com

Nonce-Based CSP: Eliminating Unsafe-Inline

The biggest challenge in implementing WordPress Content Security Policy is handling inline scripts and styles. WordPress and its plugins heavily rely on inline JavaScript for admin functionality, form handling, and interactive features.

Setting 'unsafe-inline' defeats much of CSP's purpose. The solution is nonces with CSP hashing.

WordPress nonces are already a core security feature. When you output an inline script, you can add a script-src CSP header that includes a hash of that specific script:

<?php
// In your plugin
echo '<script type="application/json">';
echo json_encode(array('nonce' => wp_create_nonce('my_action')));
echo '</script>';
?>

Then set a CSP header that includes a hash of that exact script:

script-src 'self' 'sha256-abc123...xyz'

However, generating hashes is tedious and breaks on even minor whitespace changes. The better approach is using nonce-based CSP, where the script tag itself includes a nonce attribute:

<?php
// Generate a nonce
$nonce = wp_create_nonce('my_action');

// Output script with nonce attribute
echo '<script nonce="' . esc_attr($nonce) . '">';
echo 'console.log("Safe inline script");';
echo '</script>';
?>

Then set the CSP header:

script-src 'self' 'nonce-abc123xyz'

The browser checks if the script's nonce matches the one in the CSP header. If it matches, the script executes. If not, it's blocked.

Here's how to implement this in a WordPress plugin:

<?php
// In your plugin's main file
class My_Plugin {
    public function __construct() {
        add_action('wp_head', array($this, 'set_csp_header'));
        add_action('admin_head', array($this, 'set_admin_csp_header'));
    }

    public function set_csp_header() {
        // Generate a nonce for this request
        $nonce = wp_create_nonce('my_plugin_nonce');
        
        // Set CSP header with nonce
        header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . esc_attr($nonce) . "'; style-src 'self' 'unsafe-inline'");
        
        // Store nonce in data attribute for JavaScript access
        echo '<script nonce="' . esc_attr($nonce) . '">';
        echo 'window.myPluginNonce = ' . wp_json_encode($nonce) . ';';
        echo '</script>';
    }

    public function set_admin_csp_header() {
        // Similar approach for admin
        $nonce = wp_create_nonce('my_plugin_admin_nonce');
        header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . esc_attr($nonce) . "'");
    }
}

new My_Plugin();
?>

This approach allows inline scripts while maintaining strict CSP protection. The nonce is cryptographically random per request, preventing attackers from injecting scripts even if they know the CSP policy.

Report-URI and CSP Violation Monitoring

Setting a CSP policy is only half the battle. You need visibility into whether the policy is actually working and what violations are occurring. This is where report-uri comes in.

The report-uri directive tells the browser where to send CSP violation reports:

Content-Security-Policy: default-src 'self'; report-uri https://csp-reports.example.com/collect

When a resource violates the policy, the browser sends a JSON report to your endpoint:

{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "violated-directive": "script-src",
    "effective-directive": "script-src",
    "original-policy": "default-src 'self'; script-src 'self'; report-uri https://csp-reports.example.com/collect",
    "blocked-uri": "https://evil.com/malicious.js",
    "source-file": "https://example.com/page",
    "line-number": 42,
    "column-number": 10,
    "status-code": 200
  }
}

Here's how to implement CSP report collection in your WordPress plugin:

<?php
// Create an endpoint to receive CSP reports
add_action('init', function() {
    if (isset($_GET['csp-report']) && $_GET['csp-report'] === '1') {
        $data = json_decode(file_get_contents('php://input'), true);
        
        if (isset($data['csp-report'])) {
            // Log the violation
            error_log('CSP Violation: ' . wp_json_encode($data['csp-report']));
            
            // Store in database for analysis
            global $wpdb;
            $wpdb->insert(
                $wpdb->prefix . 'csp_violations',
                array(
                    'report_data' => wp_json_encode($data['csp-report']),
                    'timestamp' => current_time('mysql'),
                ),
                array('%s', '%s')
            );
        }
        
        wp_die('', '', array('response' => 204));
    }
});

// Set report-uri header
add_action('send_headers', function() {
    $report_uri = home_url('/?csp-report=1');
    header('Content-Security-Policy-Report-Only: default-src \'self\'; report-uri ' . esc_url($report_uri));
});
?>

There are also third-party CSP monitoring services like Report-URI, Sentry, or Bugsnag that can collect and analyze these reports at scale.

Content Security Policy monitoring is critical because:

  • Identify legitimate violations: Your policy might be too strict and blocking resources you intended to allow
  • Detect attacks: Unusual patterns in CSP violations might indicate an attack attempt
  • Measure impact: See how many users are affected by policy violations
  • Iterate safely: Use report-only mode to test policy changes before enforcing them

Common CSP Mistakes in WordPress Plugins

Implementing WordPress Content Security Policy is tricky because of how WordPress and plugins are structured. Here are the most common mistakes:

Mistake 1: Using 'unsafe-inline' for Both Scripts and Styles

Many developers think they need 'unsafe-inline' for everything. In reality, you can often keep styles as 'unsafe-inline' but eliminate it for scripts:

// Don't do this (too permissive)
header("Content-Security-Policy: script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");

// Better approach
header("Content-Security-Policy: script-src 'self' 'nonce-" . $nonce . "'; style-src 'self' 'unsafe-inline'");

Mistake 2: Forgetting to Escape Dynamic Nonces

If your nonce is dynamic (which it should be), always escape it in the header:

// Wrong - potential header injection
header("Content-Security-Policy: script-src 'self' 'nonce-" . $nonce . "'");

// Right - sanitized
header("Content-Security-Policy: script-src 'self' 'nonce-" . esc_attr($nonce) . "'");

Mistake 3: Not Accounting for Third-Party Plugins

If your plugin is meant to work alongside other plugins, don't set a strict CSP that breaks them. Use Content-Security-Policy-Report-Only mode first:

// First, use report-only to identify violations
header('Content-Security-Policy-Report-Only: ...');

// Once you're confident, enforce it
header('Content-Security-Policy: ...');

Mistake 4: Blocking Form Submissions with form-action

The form-action directive is sometimes too strict. In WordPress, forms might submit to plugins you don't control:

// Overly strict
header("Content-Security-Policy: form-action 'self'");

// Better for WordPress ecosystem
header("Content-Security-Policy: form-action 'self' https: wss:");

Mistake 5: Ignoring the Admin Dashboard

Many developers apply CSP only to the frontend but forget about the admin dashboard. WordPress admin heavily relies on inline scripts:

<?php
// Handle both frontend and admin differently
if (is_admin()) {
    // More permissive for admin
    header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'");
} else {
    // Strict for frontend
    header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . esc_attr($nonce) . "'");
}
?>

Implementing CSP in Your WordPress Plugin Step by Step

Let's build a complete, production-ready CSP implementation for a WordPress plugin:

<?php
/**
 * Plugin Name: My Secure Plugin
 * Plugin URI: https://example.com
 * Description: A plugin with proper CSP implementation
 * Version: 1.0.0
 * Author: Your Name
 */

class My_Secure_Plugin {
    private $nonce;
    
    public function __construct() {
        add_action('wp_head', array($this, 'set_frontend_csp'));
        add_action('admin_head', array($this, 'set_admin_csp'));
        add_action('wp_enqueue_scripts', array($this, 'enqueue_scripts'));
        add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
    }
    
    public function set_frontend_csp() {
        // Generate nonce only once per request
        $this->nonce = wp_create_nonce('my_secure_plugin');
        
        $csp = "default-src 'self'; ";
        $csp .= "script-src 'self' 'nonce-" . esc_attr($this->nonce) . "' https://cdn.jsdelivr.net; ";
        $csp .= "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; ";
        $csp .= "img-src 'self' data: https:; ";
        $csp .= "font-src 'self' https://fonts.gstatic.com; ";
        $csp .= "connect-src 'self' https://api.example.com; ";
        $csp .= "report-uri " . esc_url(home_url('/?my-csp-report=1'));
        
        // Check if we should use report-only mode (optional filter for testing)
        $report_only = apply_filters('my_secure_plugin_csp_report_only', false);
        $header_name = $report_only ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
        
        header($header_name . ': ' . $csp);
    }
    
    public function set_admin_csp() {
        // Admin dashboard can be slightly more permissive
        $this->nonce = wp_create_nonce('my_secure_plugin_admin');
        
        $csp = "default-src 'self'; ";
        $csp .= "script-src 'self' 'nonce-" . esc_attr($this->nonce) . "' 'unsafe-eval'; ";
        $csp .= "style-src 'self' 'unsafe-inline'; ";
        $csp .= "img-src 'self' data: https:; ";
        $csp .= "report-uri " . esc_url(admin_url('admin-ajax.php?action=my_csp_report'));
        
        header('Content-Security-Policy: ' . $csp);
    }
    
    public function enqueue_scripts() {
        wp_enqueue_script('my-plugin-script', plugins_url('js/main.js', __FILE__));
        
        // Pass nonce to frontend via localization
        wp_localize_script('my-plugin-script', 'myPluginData', array(
            'nonce' => wp_create_nonce('my_plugin_action'),
            'ajaxUrl' => admin_url('admin-ajax.php'),
        ));
    }
    
    public function enqueue_admin_scripts() {
        wp_enqueue_script('my-plugin-admin', plugins_url('js/admin.js', __FILE__));
    }
}

// Initialize the plugin
new My_Secure_Plugin();

// Handle CSP reports
add_action('init', function() {
    if (isset($_GET['my-csp-report']) && $_GET['my-csp-report'] === '1') {
        $data = json_decode(file_get_contents('php://input'), true);
        if (isset($data['csp-report'])) {
            // Log CSP violation (you could also send to external service)
            error_log('[CSP Violation] ' . wp_json_encode($data['csp-report']));
        }
        wp_die('', '', array('response' => 204));
    }
});
?>

And the corresponding JavaScript file (js/main.js):

(function() {
    // Access data passed from PHP
    const pluginData = window.myPluginData || {};
    
    // Make AJAX requests with proper nonce
    const makeRequest = async (action, data = {}) => {
        const response = await fetch(pluginData.ajaxUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                action: action,
                nonce: pluginData.nonce,
                ...data,
            }),
        });
        return response.json();
    };
    
    // Example usage
    document.addEventListener('DOMContentLoaded', () => {
        console.log('Plugin initialized securely');
    });
})();

Testing and Validating Your CSP Implementation

Before deploying your WordPress Content Security Policy, thoroughly test it. Use these tools and techniques:

Use browser developer tools: Open Chrome DevTools (F12), go to Console, and look for CSP violation messages. They're typically highlighted in red with details about what was blocked.

Use a CSP validator: Online tools like the CSP Evaluator analyze your policy for weaknesses.

Use report-only mode: Deploy with Content-Security-Policy-Report-Only header first. This logs violations without blocking anything. Monitor the reports for a week or two, then switch to enforcement.

Test with WP HealthKit: Our automated plugin auditing system can scan your plugin code and flag CSP violations and unsafe patterns. You can upload your plugin for analysis at /upload.

Gradual rollout: If you're a plugin vendor, consider rolling out CSP in phases:

  1. Week 1: Use report-only mode in development
  2. Week 2: Use report-only mode on staging server with real traffic
  3. Week 3: Use report-only mode for beta users
  4. Week 4: Enforce CSP for all users

Best Practices for WordPress CSP

Use the Strictest Policy That Works

Start very restrictive and relax only where necessary:

default-src 'none'; 
script-src 'self' 'nonce-...'; 
style-src 'self';
img-src 'self';

Then gradually add sources as needed.

Separate Frontend and Admin Policies

WordPress admin and frontend have different requirements. Don't apply the same policy to both:

if (is_admin()) {
    // Admin-specific CSP (can be more permissive)
} else {
    // Frontend CSP (should be strict)
}

Document Your CSP Choices

Include comments in your code explaining why each directive is set:

<?php
// We allow 'unsafe-inline' for styles because WordPress admin
// heavily relies on inline style generation. We use nonces for scripts instead.
$csp = "style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-" . $nonce . "'";
?>

Monitor and Iterate

CSP is not a "set and forget" feature. Monitor your CSP reports regularly:

  • Are legitimate resources being blocked?
  • Are there suspicious patterns indicating attacks?
  • Do you need to add new sources?

Combine with Other Security Headers

CSP works best alongside other security headers like X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security:

<?php
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header('Content-Security-Policy: ...');
?>

Additional Resources

Frequently Asked Questions

What's the difference between Content-Security-Policy and Content-Security-Policy-Report-Only headers?

Content-Security-Policy enforces the policy—the browser blocks violations. Content-Security-Policy-Report-Only only reports violations without blocking them. Use report-only mode to test policies without breaking functionality.

Can I use CSP with inline event handlers like onclick?

No, CSP blocks inline event handlers by default. Instead, use addEventListener() in external scripts or scripts with nonces.

Do I need CSP if I already have escaping and input validation?

CSP provides defense-in-depth. Even with perfect escaping, a plugin vulnerability could introduce unescaped content. CSP catches these failures at the browser level.

What's the performance impact of CSP?

CSP has minimal performance impact. The header adds a few bytes to responses, and the browser's policy enforcement is very efficient. The slight overhead is negligible compared to the security benefit.

How do I handle CSP with WordPress multisite?

Apply CSP headers at the blog level using hooks. Each blog can have its own policy based on its installed plugins:

<?php
// In a must-use plugin
add_action('send_headers', function() {
    // Each blog's CSP
    $policy = apply_filters('blog_csp_header', 'default-src \'self\'');
    header('Content-Security-Policy: ' . $policy);
});
?>

Can third-party plugins break my CSP?

Yes, if they use unsafe patterns. This is why monitoring with report-uri is essential. WP HealthKit can audit your plugins to identify CSP-violating code before you install them.

Conclusion

WordPress Content Security Policy is no longer optional for serious WordPress developers. It's a fundamental security control that protects your users and your reputation. By implementing nonce-based CSP, monitoring violations through report-uri, and avoiding common mistakes, you create a robust defense against injection attacks.

The implementation is straightforward with modern WordPress. Start with report-only mode, validate your policy, then enforce it. Combine CSP with other security headers and security practices for defense-in-depth.

Your WordPress plugins deserve better security. Upload your plugin to WP HealthKit to get an automated security audit that checks for CSP violations, unsafe patterns, and other vulnerabilities. Our analysis will identify exactly what needs fixing and provide actionable recommendations for implementation.


Want to make sure your WordPress plugin follows CSP best practices? Upload to WP HealthKit for an instant security audit that identifies CSP violations and unsafe code patterns before they reach production.

Ready to audit your plugin?

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

Comments

WordPress Content Security Policy: A Complete Plugin Guide | WP HealthKit