Trust, security and accessibility
Security headers including a real CSP and the compromise it requires, the API validation pipeline, timing-safe secret comparison, and the accessibility work that is also SEO work.
6 min read · updated 2026-07-26· last reviewed 2026-07-26
The headers, and what each one prevents
The principle
Six headers, each preventing a specific attack, all set once in one module. There is no judgement involved in five of them. The sixth, CSP, requires a real decision.
| Header | Prevents |
|---|---|
Strict-Transport-Security | A plain-http first request that can be intercepted |
X-Content-Type-Options: nosniff | A browser executing a file the server labelled as text |
X-Frame-Options: DENY | Clickjacking, in older browsers |
Referrer-Policy | Full URLs, including query tokens, leaking to third parties |
Permissions-Policy | Camera, microphone, geolocation prompts from any embedded content |
Content-Security-Policy | Remote script loading, base-tag hijacking, form exfiltration |
The implementation
export function securityHeaders(opts: SecurityHeaderOptions = {}): HeaderRule[] {
return [
{ key: "Strict-Transport-Security", value: `max-age=63072000; includeSubDomains; preload` },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Content-Security-Policy", value: contentSecurityPolicy(opts) },
];
}CSP, and the compromise nobody writes down
The principle
A strict script-src on a statically prerendered React app requires one of three things:
- A per-request nonce, which means reading headers in the root layout and giving up static prerendering on every route.
- Per-page hashes, which change on every build and cannot be expressed in a static header.
'unsafe-inline'.
Frameworks that stream a serialised payload through inline scripts, which is most of them now, make this unavoidable. A static-first site takes option 3 and compensates everywhere else.
That compensation is real, not a consolation prize. With no external script origins permitted, object-src 'none', base-uri 'self' and a locked form-action, you have still blocked remote script loading, plugin execution, base-tag hijacking and form exfiltration. What remains permitted is inline script, which matters if you already have an XSS hole and matters not at all if you do not.
The implementation
function scriptSrcValue(opts: SecurityHeaderOptions): string {
const sources = ["'self'", ...(opts.scriptSrc ?? [])];
if (opts.nonce) {
return [`'nonce-${opts.nonce}'`, "'strict-dynamic'", "https:", ...sources].join(" ");
}
return [...sources, "'unsafe-inline'"].join(" ");
}Both modes are supported, and the choice is a config value rather than a rewrite.
The API validation pipeline
The principle
Every endpoint that accepts a body runs the same steps in the same order. Hand-rolling a subset per route is how one endpoint ends up without a rate limit, and it is always the one that sends email.
- Origin check against an allowlist
Content-Type: application/jsonenforcedContent-Lengthchecked against a cap- Rate limit
- Schema parse
- Sanitise
- Respond, without revealing which check failed
Order matters. Rate limiting after parsing means an attacker can force you to parse unlimited JSON. The Content-Length check before reading the body means a large payload is rejected before it is buffered.
The implementation
export async function guard(request: Request, opts: GuardOptions) {
if (!isAllowedOrigin(request.headers.get("origin"), opts.allowedOrigins)) {
return { ok: false, response: json({ error: "Forbidden" }, 403) };
}
if (!request.headers.get("content-type")?.includes("application/json")) {
return { ok: false, response: json({ error: "Unsupported Media Type" }, 415) };
}
const length = Number(request.headers.get("content-length") ?? "0");
if (length > opts.maxBytes) {
return { ok: false, response: json({ error: "Payload Too Large" }, 413) };
}
const limited = await rateLimit(opts.key, opts.limit);
if (!limited.success) {
return { ok: false, response: json({ error: "Too Many Requests" }, 429) };
}
const parsed = opts.schema.safeParse(await request.json());
if (!parsed.success) {
return { ok: false, response: json({ error: "Invalid request" }, 400) };
}
return { ok: true, data: parsed.data };
}The error messages are deliberately uninformative. "Invalid request" tells an attacker nothing about which field failed validation.
Timing-safe comparison
The principle
token === expected returns as soon as two bytes differ. That leaks the length of the matching prefix to anyone who can measure response time across enough requests, which is a practical attack on a low-latency endpoint.
Compare every byte regardless, and add a deliberate delay on failure so an online guessing attack is slow.
The implementation
export function timingSafeEqual(a: string, b: string): boolean {
const aBytes = new TextEncoder().encode(a);
const bBytes = new TextEncoder().encode(b);
let mismatch = aBytes.length === bBytes.length ? 0 : 1;
const length = Math.max(aBytes.length, bBytes.length);
for (let i = 0; i < length; i += 1) mismatch |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
return mismatch === 0;
}Note that the length comparison folds into the same accumulator rather than returning early, because an early return on length is itself a timing signal.
Accessibility is also indexing
The principle
Most of the accessibility work on a content site is the same work as making it machine-readable. Semantic landmarks, real headings, native disclosure elements and keyboard operability all help a screen reader and a crawler for the same reason: both are consuming the document without seeing it.
Three items carry most of the value.
A skip link as the first focusable element. Without it a keyboard user tabs through 30 navigation items on every page.
Native <details> for every accordion. The content is in the HTML whether the disclosure is open or closed, which serves both a screen reader and a crawler, and needs no JavaScript.
16px minimum on inputs. iOS zooms the viewport when a focused input has a font-size below 16px. It is the single most common mobile defect on otherwise good sites, and it is one CSS rule.
The implementation
input, select, textarea, button {
font-size: max(16px, 1em);
}.skip-link {
position: absolute;
transform: translateY(-200%);
}
.skip-link:focus-visible {
transform: translateY(0);
}Consent that actually gates
The principle
A banner that appears after the analytics script has already initialised is decorative. It claims compliance it does not deliver, which is worse than having no banner at all, because it creates a documented false statement.
Gate the initialisation itself, not a flag inside an already-loaded tracker.
The implementation
async function loadPostHog() {
if (client) return client;
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (!key) return null;
loading ??= import("posthog-js").then((mod) => { /* init */ });
return loading;
}The import is dynamic and only happens after consent. Verify it in a fresh profile with the network tab open: zero requests to the analytics host before the banner is answered.
Checks this chapter covers
Each one has a command you can run against your own site.
- →HSTS with a long max-age, includeSubDomains and preloadsec-hsts
- →X-Content-Type-Options nosniffsec-nosniff
- →Framing denied through both X-Frame-Options and frame-ancestorssec-frame-deny
- →Referrer-Policy set to strict-origin-when-cross-originsec-referrer-policy
- →Permissions-Policy denying camera, microphone, geolocation and paymentsec-permissions-policy
- →A real Content-Security-Policy, with the compromises documentedsec-csp
- →Secret comparison is timing-safe, with a deliberate delay on failuresec-timing-safe-compare
- →Every write endpoint runs the same validation pipeline in the same ordersec-api-validation-pipeline
- →Every interactive element is keyboard reachable with a visible focus ringa11y-keyboard-operable
- →Semantic landmarks and a skip link as the first focusable elementa11y-landmarks-skip-link
- →Terms, privacy, licence and refund pages exist, are linked sitewide, and name real processorstrust-legal-pages
- →The consent banner genuinely prevents analytics from loading before consenttrust-consent-gates-analytics