SEO for Solos
Chapter 14Free

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.

HeaderPrevents
Strict-Transport-SecurityA plain-http first request that can be intercepted
X-Content-Type-Options: nosniffA browser executing a file the server labelled as text
X-Frame-Options: DENYClickjacking, in older browsers
Referrer-PolicyFull URLs, including query tokens, leaking to third parties
Permissions-PolicyCamera, microphone, geolocation prompts from any embedded content
Content-Security-PolicyRemote script loading, base-tag hijacking, form exfiltration

The implementation

typescript
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:

  1. A per-request nonce, which means reading headers in the root layout and giving up static prerendering on every route.
  2. Per-page hashes, which change on every build and cannot be expressed in a static header.
  3. '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

typescript
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.

  1. Origin check against an allowlist
  2. Content-Type: application/json enforced
  3. Content-Length checked against a cap
  4. Rate limit
  5. Schema parse
  6. Sanitise
  7. 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

typescript
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

typescript
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

css
input, select, textarea, button {
  font-size: max(16px, 1em);
}
css
.skip-link {
  position: absolute;
  transform: translateY(-200%);
}
.skip-link:focus-visible {
  transform: translateY(0);
}

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

typescript
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.

Product analytics are optional. On the skill page, X advertising measurement is also optional and shares your visit and ad identifiers with X. Both are off until you allow them. Privacy policy.