Skip to content

Security headers

2 min read · main part only; folded sections are optional

After this you can answer: What do the security headers protect against, and how do you check them?

flowchart TB
W["web page answer"] --> WH["CSP: nonce + strict-dynamic<br/>frame denial; nosniff<br/>HSTS: later HTTPS visits, 1 day"]
A["API answer"] --> AH["CSP: default-src 'none'<br/>frame denial; nosniff"]
A --> M["bad Origin or cross-site<br/>mutation metadata: 403"]
  1. Script trust. The web proxy makes a nonce per response. In browsers supporting strict-dynamic, scripts with that nonce can also load scripts without it. This blocks injected script tags without that trust; it does not fix every cross-site scripting (XSS) bug.
  2. Framing. X-Frame-Options: DENY and frame-ancestors 'none' stop other sites from showing our pages in a frame (clickjacking).
  3. Sniffing and referrer. X-Content-Type-Options: nosniff prevents guessing a script or style’s MIME type. Referrer-Policy: strict-origin-when-cross-origin sends only the origin to another HTTPS site, and no referrer when downgrading from HTTPS to HTTP.
  4. HTTP Strict Transport Security (HSTS). Strict-Transport-Security: max-age=86400 tells the browser to use only HTTPS for a day after receiving this header over HTTPS. Later typed http:// visits upgrade before any HTTP request; the first visit is not protected by this header.

The web policy is built in apps/web/src/lib/security-policy.ts → securityPolicy and set per request in apps/web/src/proxy.ts → proxy, which also sends a Permissions-Policy that turns off camera, microphone and geolocation. HSTS is sent only by a production build, only over HTTPS, and never for localhost (apps/web/next.config.ts).

The API uses default-src 'none' in its CSP and sends the same framing and sniffing headers. For every request that is not GET, HEAD or OPTIONS (every tRPC mutation is a POST), it requires the exact web Origin and rejects Sec-Fetch-Site: cross-site, answering 403 on failure (apps/api/src/http/security.ts → mutationAllowed). That blocks cross-site request forgery (CSRF): unwanted changes from another site, alongside the SameSite cookie.

See the web app on the map →

Not an AWS setting: the apps send these headers. Open the browser’s developer tools → Network → a page → Response headers.

SymptomCauseWhere to look
A page loads without styles; console shows a CSP errorThe CSP does not allow that sourcesecurity-policy.ts; browser console
A POST from a script returns 403Missing/wrong Origin, or Sec-Fetch-Site: cross-sitemutationAllowed
No HSTS header in CloudFront modeThe web app never sees HTTPS therenext.config.ts comment
Why is HSTS only one day?

If the certificate expires, browsers with an active HSTS policy refuse to bypass that certificate error. Restoring valid HTTPS fixes access. A one-day policy limits how long that restriction lasts if HTTPS stays broken; the lifetime can grow later.

Why a nonce instead of listing allowed script URLs?

Next.js adds inline scripts. A nonce authorizes those scripts for this response, and strict-dynamic lets them load their own chunks, without allowing every inline script.

What is the trade-off of a fresh nonce per response?

Sharing cached HTML would reuse its nonce. To keep a fresh nonce per response, MiniTickets renders per request and sets private, no-store. That costs more rendering work than shared HTML caching.

Advanced: Why HSTS skips localhost and CloudFront mode

HSTS is per host name for all ports, so an entry for localhost would force HTTPS on every local project in that browser. In CloudFront mode CloudFront talks HTTP to the ALB, so the web app never sees x-forwarded-proto: https and sends no HSTS; that would be CloudFront’s job (a response headers policy, not built). Development adds unsafe-eval and local connections to the CSP for hot reload. Browser behavior: CSP script trust and HSTS.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What does the nonce allow, and what extra trust does strict-dynamic add?
  2. Which two headers stop clickjacking?
  3. What does HSTS do that the ALB’s 301 redirect cannot?
  4. What does the API check on every POST, and what does it answer if it fails?
  5. Why does the API’s CSP say default-src 'none'?

Redraw it from memory: the web answer's headers and the API answer's headers, side by side

Try this: run curl -sI against the local web app and the local API and compare their headers

Read this book online: https://book.theversehub.in/