Headers After the First Byte Never Arrive: Emitting the Policy From a Front Controller
A security header is a promise the browser enforces for you — but only if it arrives before the body does. PHP sends headers with the response's first byte of output, which makes the ordering problem the first thing to get right:
<?php echo '<h1>Welcome</h1>'; // output has started…
header('X-Content-Type-Options: nosniff'); // Warning: Cannot modify header information -
// headers already sent by (output started at ...)
Once bytes are on the wire, header() can no longer rewrite the response head — you get that warning, the header is silently dropped, and every policy you configured is gone while the page looks fine. Three habits prevent it, and they are worth stating because the failure is invisible:
- Buffer. PHP's
output_buffering = 4096(the usual php.ini value) holds early output in memory soheader()still works; in code,ob_start()gives the same guarantee when you cannot control the ini. - Never emit stray bytes. No whitespace before
<?php, no closing?>tag in a pure-PHP file (the newline after it counts as output), no UTF-8 BOM at the top of an included file. - Send the set once, early. A front controller or an
auto_prepend_filebootstrap runs before any view, so no handler can forget a header — and no template can beat it to the wire:
<?php // bootstrap: runs before any body output
$nonce = bin2hex(random_bytes(16));
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => true,
'httponly' => true, 'samesite' => 'Lax']);
session_start();
header_remove('X-Powered-By'); // default: PHP version disclosure
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY'); // legacy, kept for old browsers
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; "
. "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests");
Mechanics worth knowing before you debug a "missing" header: a second header() with the same field name replaces the first unless you pass false as the second argument, and if your nginx config also emits the field, both are sent — multiple Content-Security-Policy headers mean the browser applies every policy and the strictest wins, which is handy later for a report-only line but a confusing bug when accidental. Verify what actually shipped with curl -I https://yoursite/ or the Network panel's response headers — never with what your code says it sent.
Where the set is emitted is itself a design decision, and every layer has a characteristic failure:
| Layer | Covers | Characteristic failure |
|---|---|---|
PHP bootstrap (header() / auto_prepend_file) |
every response the app produces; per-response nonces | any script served outside the front controller ships nothing |
Web server (nginx add_header, Apache Header set) |
static files and PHP alike; survives app bugs | two configs drift; nginx silently drops inherited add_header directives inside any location block that declares its own |
| Framework middleware | every routed request; unit-testable | bypassed by queue workers and console commands that emit responses |
| CDN / edge | every origin uniformly | propagation lag; duplicate headers when the origin keeps sending its own copy |
The usual answer for a PHP app: emit the request-dependent fields (the nonce, the cookie flags) from the bootstrap, let the web server pin the static ones (nosniff, HSTS), and assert the union in CI with a curl -I check so a header that quietly disappears fails the build instead of shipping.
Two header-level leaks are worth removing on day one. X-Powered-By: PHP/8.2.x (plus Server: banners from the web server) hands attackers version-specific vulnerability knowledge; header_remove('X-Powered-By') or expose_php = Off deletes the first one. And any header that describes your stack is doing the same job — the response should describe the content, not the machinery.
Where the deep work starts is the policy header that follows the nonce: it decides which scripts run, and it is the only header in this set that actively neutralizes an injection you failed to catch.
script-src, Nonces, and Hashes: A CSP That Blocks the Script You Missed
Content-Security-Policy is where the escaping work from Escaping Output to Prevent XSS gets a second, independent layer: escaping removes the characters a payload needs, and CSP refuses to run scripts that did not come through your pipeline. Build it directive by directive.
| Directive | Controls | Example | What it stops |
|---|---|---|---|
default-src |
fallback for any fetch directive you omit | 'self' |
unlisted everything |
script-src |
executable JavaScript | 'self' 'nonce-…' |
injected <script src> and inline handlers |
style-src |
stylesheets | 'self' 'nonce-…' |
injected CSS, data exfil via url() |
img-src |
images | 'self' data: https: |
beacons to attacker infrastructure |
connect-src |
fetch/XHR/websocket targets |
'self' https://api.example |
exfiltration channels |
object-src |
plugins (PDF/Flash legacy) | 'none' |
plugin-borne execution |
base-uri |
<base href> injection |
'none' |
relative URL hijacking |
form-action |
where forms may submit | 'https://yoursite' |
forced posting to a third party |
frame-ancestors |
who may embed the page | 'none' |
clickjacking (supersedes X-Frame-Options) |
Three ways to name an allowed script — nonce, hash, or origin — and the trade-offs differ:
// 1) nonce: fresh per response, for your own inline scripts
$nonce = bin2hex(random_bytes(16)); // ≥128 bits of base64-value
header("Content-Security-Policy: script-src 'self' 'nonce-{$nonce}'");
// 2) hash: stable for a fixed inline block — no per-request inline script needed
$block = "window.__CFG = { api: '/v1' };";
$hash = base64_encode(hash('sha256', $block, true)); // → 'sha256-<base64>'
header("Content-Security-Policy: script-src 'self' 'sha256-{$hash}'");
// 3) origins: for third-party files you cannot nonce
header("Content-Security-Policy: script-src 'self' https://cdn.example.com");
A nonce must be new on every response; a cached page that reuses one lets an attacker read the nonce from the DOM and mint matching scripts — which is also why 'strict-dynamic' (trust scripts loaded by an already-trusted script) pairs with nonces in complex apps: the nonce protects the bootstrap, strict-dynamic lets its descendants load. Hashes suit static inline blocks that never change; they are worthless for anything variable.
Roll out without breaking the site:
header("Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; "
. "object-src 'none'; report-uri /csp-reports");
-Report-Only is enforced by nobody: the browser keeps rendering but posts violations to your endpoint, so you spend a week learning that your analytics snippet uses eval() or that a template injects an inline onclick. Then promote to the enforcing header. Expect to fight these, in order: inline event handlers (need 'unsafe-inline' — avoid; move handlers to addEventListener in your JS), eval() and new Function() ('unsafe-eval' — refactor, never grant), inline <style> (nonce it the same way), and CDN scripts (add their origin or nonce the tag).
Two limits to state honestly. 'unsafe-inline' or 'unsafe-eval' in script-src surrenders most of the protection the policy buys — the OWASP cheat sheets treat them as the failure mode, not a compromise. And CSP is not a sanitizer: it limits execution, not injection, so text defacement and data exposure through loose img-src/form-action still happen — which is why the policy layers on top of the escaping rules rather than replacing them. The remaining header family changes browser behavior in a different way: how much of your URLs leak, how your content type is treated, and how your cookies travel.
Quiz
1. Your bootstrap calls header('X-Content-Type-Options: nosniff') after a template has already echoed a heading. What is the result?
2. A page generates a nonce, embeds it in script-src 'nonce-…', but a misconfigured full-page cache serves that same response to every visitor. What went wrong?
3. Which header stops a browser from treating your user-uploaded report.txt (served as text/plain) as something executable, and how?
4. A colleague proposes dropping CSRF tokens entirely now that the session cookie is set to SameSite=Strict. Which objection is accurate?
Flashcards
Question
Why must security headers be sent before any output, and how do you guarantee it?
Click to reveal answer
Answer
PHP finalizes the response head with the first body byte; after that header() warns 'headers already sent' and drops the header while the page renders fine. Guarantee it with output_buffering/ob_start, no whitespace before <?php, no closing ?> in PHP-only files, no BOM — and send the whole set once from a front-controller bootstrap.
Question
How do nonce, hash, and origin differ in script-src?
Click to reveal answer
Answer
Nonce ('nonce-<random>') validates your own inline scripts and must be fresh per response (a cached nonce is readable and forgeable). Hash ('sha256-<base64 of exact bytes>') suits fixed inline blocks and survives caching. Origin lists third-party hosts (CDN). 'strict-dynamic' trusts descendants of a nonce-approved script; 'unsafe-inline'/'unsafe-eval' void the policy.
Question
What do X-Content-Type-Options: nosniff and Referrer-Policy each prevent?
Click to reveal answer
Answer
nosniff makes the declared Content-Type authoritative so the browser stops MIME-sniffing bytes into executable types (pair with finfo-verified uploads). Referrer-Policy controls how much URL leaves with links: strict-origin-when-cross-origin sends full URL same-origin and origin cross-origin; no-referrer sends nothing; unsafe-url leaks everything including query tokens.
Question
What do the three session cookie flags buy, and what does SameSite not cover?
Click to reveal answer
Answer
HttpOnly hides the session id from document.cookie (limits theft, not action); Secure refuses plain-http transmission; SameSite=Lax/Strict withholds the cookie cross-site (CSRF layer two). Not covered: browsers without SameSite, subdomain cookie tossing, GET side effects under Lax, and dependencies reintroducing cross-site posts — CSRF tokens stay mandatory.
Question
How do frame-ancestors and X-Frame-Options relate, and how do you roll out a CSP safely?
Click to reveal answer
Answer
frame-ancestors 'none' in the CSP is the modern clickjacking control; X-Frame-Options: DENY is sent alongside because older browsers ignore CSP. Roll out with Content-Security-Policy-Report-Only plus report-uri, collect violations (inline handlers, eval, CDN origins) for a week, then promote to the enforcing header.
Revision Notes
Key Takeaways
- 1. Headers ride ahead of the body — buffer, keep files free of stray bytes, and emit the whole set from one bootstrap or the policy silently disappears behind a warning
- 2. CSP's script-src with a per-response nonce (or a hash for fixed blocks) is the only header that neutralizes an injection you missed; Report-Only ships it without breaking the site
- 3. nosniff makes your Content-Type authoritative, Referrer-Policy decides how much URL leaks, and HSTS/Permissions-Policy round out the browser-level contract
- 4. Cookie flags — Secure, HttpOnly, SameSite — are configured with session_set_cookie_params, not in handlers, and SameSite is layer two beside CSRF tokens
- 5. Send frame-ancestors and X-Frame-Options together, strip X-Powered-By, and verify with curl -I: what the code says is not what shipped
Interview Tips
- • "Which security headers would you add to a PHP app?" — walk the set with a reason each: nosniff (type confusion), CSP (script allowlist), Referrer-Policy (URL leakage), HSTS (https only), frame-ancestors/XFO (clickjacking), Permissions-Policy, plus cookie flags; a list without reasons reads like memorization
- • "Explain a CSP nonce" — per-response random secret in script-src, inline scripts carry the same nonce, freshness is the security property; immediately note the cache trap and that hashes cover fixed inline blocks
- • "Why does 'unsafe-inline' defeat the policy?" — it re-permits exactly the inline scripts the injection needs, so the allowlist collapses to the same trust level as no CSP; say how you avoid it (move handlers to addEventListener, nonce inline blocks)
- • "Your headers sometimes vanish in production — what do you check?" — headers-already-sent from stray output or BOM, duplicate field replaced or web-server-level headers overriding, and curl -I to see the actual response; show you debug the wire, not the source
Cheat Sheet
Security Headers and Cookie Flags
Emit before the first byte:
// output_buffering=4096 / ob_start() · no whitespace before <?php · no closing ?> · no BOM
header_remove('X-Powered-By');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY'); // legacy alongside frame-ancestors
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests");
- Same-name
header()replaces unlessheader($h, false); two CSP headers = both enforced (strictest wins). - Verify with
curl -I, not with what the code claims.
CSP script allowlisting — nonce | hash | origin:
| Mechanism | Use for | Trap |
|---|---|---|
'nonce-…' bin2hex(random_bytes(16)) |
own inline scripts, per response | must be fresh every response — cached nonce = attacker mints matching scripts |
'sha256-<base64>' base64_encode(hash('sha256', $block, true)) |
fixed inline blocks | worthless for variable code |
origin (https://cdn.example.com) |
third-party files | keep the list short |
'strict-dynamic' |
descendants of a nonce-approved script | pairs with nonce, not a replacement |
- Breakages in order: inline
onclick→ move toaddEventListener;eval/new Function→ refactor (never'unsafe-eval'); inline styles → nonce; CDN → origin. - Roll out with
Content-Security-Policy-Report-Only+report-uri, then promote. 'unsafe-inline'/'unsafe-eval'surrender most of the protection — OWASP treats them as failure, not compromise. CSP limits execution, not injection: keep escaping (previous XSS rules apply).
Header semantics:
| Header | Question it answers |
|---|---|
X-Content-Type-Options: nosniff |
"trust my declared type" — stops sniffing/MIME confusion (pair with accurate Content-Type, finfo on uploads) |
Referrer-Policy |
how much URL leaks: no-referrer → nothing; origin → host only; strict-origin-when-cross-origin (default) → full same-origin, origin cross-origin; never unsafe-url |
frame-ancestors 'none' + X-Frame-Options: DENY |
clickjacking — send both (old browsers ignore CSP) |
Strict-Transport-Security |
https-only for max-age (+includeSubDomains, preload deliberately) |
Permissions-Policy |
which powerful APIs (camera=(), microphone=()) the page may use |
X-Powered-By / Server |
remove — stack/version disclosure |
Cookie flags:
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>true,'httponly'=>true,'samesite'=>'Lax']);
HttpOnly = invisible to document.cookie (limits theft, not action) · Secure = never over http · SameSite = CSRF layer two beside tokens (None requires Secure).
OWASP framing: Secure Headers project — each header closes a browser-level class (execution, framing, sniffing, leakage) that input validation cannot reach.