Skip to content
advanced Phase 10 · Securing Web Applications

Security Headers and Cookie Flags

Harden responses with headers the browser enforces.

40m
0 problems
Topic Progress 0%

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 so header() 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_file bootstrap 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.

nosniff, Referrer-Policy, and Cookie Flags: Browser Behavior You Set in the Response Head

CSP decides what executes; the rest of the set decides what the browser assumes, leaks, and stores. Four controls cover the most common surprises.

X-Content-Type-Options: nosniff — stop the browser guessing. Without it, a browser that receives a response whose declared type looks wrong may sniff the bytes and act on what it finds. The dangerous cases are concrete: a user-uploaded file you serve as text/plain with sloppy headers getting treated as markup, and — the part modern browsers enforce strictly — a script or stylesheet whose Content-Type is not JavaScript/CSS being executed anyway. With nosniff, the header you send is authoritative:

header('X-Content-Type-Options: nosniff');
header('Content-Type: application/json; charset=utf-8');   // pair it with an accurate type

It is one line, has no compatibility cost, and pairs with the rule that upload handlers always sniff real bytes with finfo — the header prevents the browser from papering over a type you got wrong.

Referrer-Policy — decide how much of your URL leaves the building. URLs leak data by construction: /orders/8871?token=abc in a Referer header tells the destination page where the user came from and, with unsafe-url, everything on the query string.

Value Sent to other origins
no-referrer nothing
origin scheme + host only
strict-origin-when-cross-origin (browser default) full URL same-origin; origin cross-origin
no-referrer-when-downgrade (legacy default) full URL everywhere except https→http
unsafe-url full URL always — never set this
header('Referrer-Policy: strict-origin-when-cross-origin');

The setting matters most for pages with identifiers or search terms in the URL, and it is the header that makes no-referrer-style privacy settings your decision rather than the user's guess.

Cookie flags — the header set that travels with every request. Session security is configured here, not in the handler:

session_set_cookie_params([
    'lifetime' => 0, 'path' => '/', 'secure' => true,
    'httponly' => true, 'samesite' => 'Lax',
]);
session_start();

HttpOnly keeps document.cookie blind to the session id (limits XSS theft — not XSS action); Secure refuses transmission over plain http; SameSite is your second CSRF layer next to the tokens from CSRF Tokens for Forms — Lax as the safe default, Strict where login-link convenience does not matter, None only for genuine third-party embeds and only together with Secure.

Clickjacking — one policy, two headers. frame-ancestors 'none' in the CSP is the modern control, while X-Frame-Options: DENY (or SAMEORIGIN) is still sent alongside it because older browsers ignore CSP's directive:

header('X-Frame-Options: DENY');
// inside the CSP string:
// frame-ancestors 'none';

Anything that overlays UI over your page — a fake "delete account" button floated over the real one — dies when the page cannot be framed.

Assemble and audit rather than add one header at a time: the bootstrap from the first chapter already emits nosniff, Referrer-Policy, Permissions-Policy, HSTS, the cookie flags, and the CSP; curl -I tells you what the server actually sends; and public scoring tools will flag exactly which of the set are missing. Then check the framing answer is what you expect — OWASP's Secure Headers project lists this collection precisely because each header closes a class of browser-level attack that no amount of input validation addresses. From here the roadmap moves on to Reading Requests with Superglobals, where $_SERVER, header() timing, and http_response_code() — the primitives this whole set rides on — get their own treatment.

Quiz

1. Your bootstrap calls header('X-Content-Type-Options: nosniff') after a template has already echoed a heading. What is the result?

Question 1 options

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?

Question 2 options

3. Which header stops a browser from treating your user-uploaded report.txt (served as text/plain) as something executable, and how?

Question 3 options

4. A colleague proposes dropping CSRF tokens entirely now that the session cookie is set to SameSite=Strict. Which objection is accurate?

Question 4 options

Flashcards

Question

Why must security headers be sent before any output, and how do you guarantee it?

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?

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?

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?

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?

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 unless header($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 to addEventListener; 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.

Continue Learning