HTTP security headers, CSP & cookie flags
When to use this skill
Use this skill whenever code touches HTTP responses on any WordPress surface:
- Adding response headers (front-end, wp-admin, or wp-login.php).
- Building a Content-Security-Policy, migrating Report-Only to enforced, or wiring script nonces.
- Choosing frame protection without breaking the Customizer preview or oEmbed frames.
- Setting Secure / HttpOnly / SameSite on a custom cookie, or forcing Secure on core auth cookies.
- Deciding what REST responses may reflect into Access-Control-Allow-Origin.
- Reviewing code that calls
header(), reads$_SERVER['HTTP_ORIGIN'], or filterswp_headers.
Response headers are the second layer. Output escaping prevents XSS; headers contain the blast radius when escaping was missed somewhere, and they stop whole attack classes escaping cannot: clickjacking, MIME sniffing, and inline script injection. Agents rarely add headers. When they do, they tend to add a blanket CSP that admins disable, or CORS wide open.
Related: see the output-escaping skill for the primary XSS defense, the
rest-api-security skill for endpoint authorization, and the
wp-hardening-best-practices skill for server-layer TLS and .htaccess config.
Core principles (and why they matter)
- Headers mitigate; escaping prevents. A CSP is not an XSS fix. It limits what an injected script can load and whether it runs at all. Ship both.
- Three surfaces, one policy. Front-end template loads fire
send_headersand thewp_headersfilter. wp-login.php and wp-admin do not run the main query, so they never reach that code: uselogin_initfor the login screen andadmin_initfor admin, both before output, with one shared function callingheader().login_headfires inside the login page<head>, after HTML output has started:header()there is a silent no-op. It is only good for<meta http-equiv>fallbacks. - Frame control must permit same-origin framing. The Customizer and theme
previews iframe the front-end from the same origin; oEmbed previews frame
posts.
X-Frame-Options: SAMEORIGIN(or CSPframe-ancestors 'self') is the safe default. Blanket DENY breaks the Customizer preview. - CSP ships incrementally. Start with
Content-Security-Policy-Report-Only, triage violation reports, then enforce. Enforcing a strict policy on day one breaks the site and teaches admins to delete your headers. - A CSP nonce is not a CSRF nonce. Generate it fresh per response with
random_bytes()+bin2hex().wp_create_nonce()returns a CSRF token: user-bound, reused across requests for the session window, and designed to be printed into the page. Wrong tool forscript-src 'nonce-...'. - Inline scripts do not inherit the nonce.
wp_add_inline_script()output is printed bywp_get_inline_script_tag(), not through yourscript_loader_tagfilter. Nonce it via thewp_inline_script_attributesfilter. - Restrict CORS with core's allowlist, never reflection. Core's
rest_send_cors_headers()reflects the requestOriginintoAccess-Control-Allow-Origintogether withAccess-Control-Allow-Credentials: true, and does not consult the allowlist. Filterhttp_originso disallowed origins produce no CORS headers at all; extend the allowlist withallowed_http_origins. Never sendAccess-Control-Allow-Origin: *together with credentials. - Cookie flags are cheap and mandatory. Every custom cookie:
Secure,HttpOnly,SameSite, via the PHP 7.3+ options array. Core auth cookies already sendHttpOnly(hardcoded inwp_set_auth_cookie(), noauth_cookie_httponlyhook exists); the filterable part isSecureviasecure_auth_cookie/secure_logged_in_cookie. - HSTS is a domain commitment. Send it only from an HTTPS-only site: once cached, a broken certificate makes the domain unreachable. Server-layer config (nginx / .htaccess) is the better home.
- Do not strip headers you did not add (for example, removing core's frame protection) without a documented reason and a replacement.
Step-by-step implementation
Add the baseline to the front-end through
wp_headers(full module:references/secure-security-headers.php):add_filter( 'wp_headers', 'myplugin_front_end_security_headers' ); function myplugin_front_end_security_headers( $headers ) { $headers['X-Content-Type-Options'] = 'nosniff'; $headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'; $headers['X-Frame-Options'] = 'SAMEORIGIN'; return $headers; }Cover the other two surfaces with one shared function hooked to
login_initandadmin_init; it callsheader( 'Header: value' )per header, guarded byheaders_sent().Decide frame policy:
SAMEORIGIN/frame-ancestors 'self'unless the site is documented to never be framed. Do not use DENY site-wide.Write the CSP and ship it as
Content-Security-Policy-Report-Onlyfirst. Buildscript-srcaround a per-response nonce:function myplugin_csp_nonce() { static $nonce = null; if ( null === $nonce ) { $nonce = bin2hex( random_bytes( 16 ) ); } return $nonce; }Attach the nonce to enqueued scripts through
script_loader_tag, limited to your own handles. Noncewp_add_inline_script()output throughwp_inline_script_attributes. If you enforcestyle-src, do the same viastyle_loader_tag.Watch the Report-Only console/violation output until it is quiet, then rename the header to
Content-Security-Policy.Set cookie flags with the PHP 7.3+ options array. On HTTPS-only sites, force
Secureon core auth cookies withsecure_auth_cookie.Restrict REST CORS: filter
http_originto return an empty string unless the origin is inget_allowed_http_origins(); add partner origins through theallowed_http_originsfilter.Verify:
curl -sIone URL per surface, plus the browser console for CSP violations. Re-check after enabling full-page caching: a cache that freezes HTML freezes the per-response nonce.
Supporting references
| Reference | Load when |
|---|---|
| HTTP security headers checklist | Before final verification of the http security headers controls. |
| HTTP headers cheat sheet | Choosing the applicable WordPress API or control for HTTP headers and CSP. |
| Secure HTTP security headers | Implementing baseline headers across WordPress surfaces, Report-Only CSP nonces, cookie flags, and REST origin restrictions. |
Common AI mistakes / anti-patterns
Mistake 1 - CSP theater
// ❌ Insecure: permits everything; any inline or remote script runs. Pure theater.
header( 'Content-Security-Policy: script-src * unsafe-inline' );
// ✅ Secure: self plus a per-response nonce; observed in Report-Only before enforcing.
header( "Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . myplugin_csp_nonce() . "'" );
Mistake 2 - Enforcing without ever running Report-Only
// ❌ Insecure: strict enforced CSP from day one breaks theme/plugin assets;
// the admin's first fix is deleting your header.
header( "Content-Security-Policy: default-src 'self'" );
// ✅ Secure: identical policy, observed only. Enforce after violations are triaged.
header( "Content-Security-Policy-Report-Only: default-src 'self'" );
Mistake 3 - Reflecting Origin with credentials
// ❌ Insecure: any site can read logged-in users' responses; reflection plus
// credentials is a full cross-origin grant to everyone.
header( 'Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN'] );
header( 'Access-Control-Allow-Credentials: true' );
// ✅ Secure: keep core's header logic, and restrict what it may reflect via the
// http_origin filter. Empty origin means core sends no CORS headers.
add_filter( 'http_origin', 'myplugin_restrict_http_origin' );
function myplugin_restrict_http_origin( $origin ) {
if ( '' !== $origin && ! in_array( $origin, get_allowed_http_origins(), true ) ) {
return '';
}
return $origin;
}
Mistake 4 - Headers on the front-end only
// ❌ Insecure: login and admin get nothing; the login form is a favorite
// framing and injection target.
add_filter( 'wp_headers', 'myplugin_front_end_security_headers' ); // and nothing else
// ✅ Secure: one shared function, three surfaces.
add_action( 'login_init', 'myplugin_login_security_headers' );
add_action( 'admin_init', 'myplugin_admin_security_headers' );
Mistake 5 - Using wp_create_nonce() as a CSP nonce
// ❌ Insecure: a CSRF token is user-bound and reused across requests; it is
// also printed into forms anyway. Leakage stays valid for the session window.
$nonce = wp_create_nonce( 'csp' );
header( "Content-Security-Policy: script-src 'self' 'nonce-$nonce'" );
// ✅ Secure: fresh unpredictable value per response.
header( "Content-Security-Policy: script-src 'self' 'nonce-" . bin2hex( random_bytes( 16 ) ) . "'" );
Mistake 6 - Blanket X-Frame-Options: DENY
// ❌ Insecure: DENY forbids same-origin framing; the Customizer preview and
// oEmbed previews stop working.
$headers['X-Frame-Options'] = 'DENY';
// ✅ Secure: allow same-origin framing only.
$headers['X-Frame-Options'] = 'SAMEORIGIN';
// Or in the CSP: frame-ancestors 'self'
Mistake 7 - SameSite=None without Secure
// ❌ Insecure: browsers reject SameSite=None over insecure contexts; the
// cookie silently disappears, or ships cross-site without TLS.
setcookie( 'myplugin_session', $token, array( 'samesite' => 'None' ) );
// ✅ Secure: None only together with Secure; default to Lax otherwise.
setcookie(
'myplugin_session',
$token,
array(
'expires' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'None',
)
);
Correct code examples
The complete module (all three surfaces, CSP Report-Only with nonce pipeline,
custom cookie helper, HTTPS-only auth cookies, REST origin restriction) is in
references/secure-security-headers.php.
Per-header values, what each header stops, and caveats are in
references/headers-cheatsheet.md.
Pre-ship review list: references/checklist.md.
Checklist
- Front-end headers set via
wp_headers(orsend_headers), not scatteredheader()calls in templates. - wp-login.php covered via
login_init(notlogin_head: that fires after output starts); wp-admin viaadmin_init. - Frame protection is
SAMEORIGIN/frame-ancestors 'self', not site-wide DENY. - CSP ran as
Content-Security-Policy-Report-Onlyfirst; violations triaged before enforcement. - CSP nonce generated per response with
random_bytes()+bin2hex(). - Enqueued scripts nonced only for plugin handles via
script_loader_tag. - Inline scripts nonced via
wp_inline_script_attributes. - Full-page caching behavior checked: no frozen nonce in cached HTML.
- No
Access-Control-Allow-Origin: *combined withAccess-Control-Allow-Credentials: true; REST origins restricted through core's allowlist filters. - Custom cookies set
Secure,HttpOnly,SameSite;SameSite=Noneonly withSecure. - Core auth cookie
Secureforced viasecure_auth_cookie/secure_logged_in_cookieon HTTPS-only sites. - HSTS sent only on HTTPS-only sites, or configured at the server layer.
- No pre-existing headers removed without a documented reason.
- Verified per surface with
curl -sIand the browser CSP console.
Official references
wp_headersfiltersend_headersactionlogin_initactionlogin_headactionadmin_initactionscript_loader_tagfilterstyle_loader_tagfilterwp_inline_script_attributesfilterwp_add_inline_script()wp_enqueue_script()wp_get_inline_script_tag()nocache_headers()rest_send_cors_headers()get_http_origin()is_allowed_http_origin()get_allowed_http_origins()http_originfilterallowed_http_originsfiltersecure_auth_cookiefiltersecure_logged_in_cookiefilterwp_set_auth_cookie()- OWASP HTTP Headers Cheat Sheet
- MDN: Content Security Policy (CSP)
- MDN: CSP frame-ancestors