Trust, security and accessibility
Trust, security and accessibility covers 14 of the SEO for Soloschecklist's implementation checks. Headers, policy pages and keyboard operability. Partly ranking signals, mostly the difference between a site people trust and one they bounce from.
0 of 14
Saved in this browser
14 checks shown.
Trust, security and accessibility
Headers, policy pages and keyboard operability. Partly ranking signals, mostly the difference between a site people trust and one they bounce from.
HSTS with a long max-age, includeSubDomains and preload2/S
Why it matters
Without it the first request of every session is plain http and redirectable. With preload the browser never makes that request at all.
How to verify
curl -sI https://example.com | grep -i strict-transport.
Implementation
kit/config/headers.ts/** * Code Kit: security and cache headers. * Implements checks: sec-hsts, sec-nosniff, sec-frame-deny, sec-referrer-policy, * sec-permissions-policy, sec-csp, perf-immutable-assets * * Every value is a typed argument. This module reads no environment variables. */ export type SecurityHeaderOptions = { /** Extra origins allowed to serve scripts. Keep this list empty if you can. */ scriptSrc?: string[]; /** Extra origins the page may connect to (fetch, XHR, WebSocket, beacon). */ connectSrc?: string[]; /** Extra origins allowed to serve images. */ imgSrc?: string[]; /** Extra origins allowed to serve fonts. */ fontSrc?: string[]; /** Extra origins allowed as form action targets, e.g. a hosted checkout. */ formAction?: string[]; /** Extra origins allowed to be framed by this site, e.g. an embedded player. */ frameSrc?: string[]; /** * Emit `Content-Security-Policy-Report-Only` instead of the enforcing header. * Use this for one deploy when tightening an existing policy. */ reportOnly?: boolean; /** Endpoint that receives CSP violation reports. */ reportUri?: string; /** * Nonce-based script policy. Requires middleware that generates a per-request * nonce, which forces dynamic rendering. Off by default because a * static-first site cannot pay that cost. See the comment on `scriptSrcValue`. */ nonce?: string; /** `max-age` for HSTS in seconds. Default: two years. */ hstsMaxAge?: number; }; export type HeaderRule = { key: string; value: string }; const DEFAULT_HSTS_MAX_AGE = 63_072_000; // 2 years /** * Script policy. * * Next.js App Router streams the RSC payload through inline * `self.__next_f.push(...)` scripts. Allowing them requires one of: * * 1. a per-request nonce, which means reading headers in the root layout and * giving up static prerendering on every route, or * 2. per-page hashes, which change on every build and cannot be expressed in * a static header, or * 3. `'unsafe-inline'`. * * A static-first site takes option 3 and compensates elsewhere: no external * script origins, `object-src 'none'`, `base-uri 'self'`, and a locked * `form-action`. That combination still blocks the common injection paths * (remote script loading, base-tag hijacking, form exfiltration, plugin * execution). Document the tradeoff rather than pretending it away. */ function scriptSrcValue(opts: SecurityHeaderOptions): string { const sources = ["'self'", ...(opts.scriptSrc ?? [])]; if (opts.nonce) { // Nonce mode: 'strict-dynamic' lets nonced loaders pull their own chunks. return [`'nonce-${opts.nonce}'`, "'strict-dynamic'", "https:", ...sources].join(" "); } return [...sources, "'unsafe-inline'"].join(" "); } export function contentSecurityPolicy(opts: SecurityHeaderOptions = {}): string { const directives: Array<[string, string[]]> = [ ["default-src", ["'self'"]], ["script-src", [scriptSrcValue(opts)]], // Tailwind v4 with `experimental.inlineCss` emits a <style> tag per route. ["style-src", ["'self'", "'unsafe-inline'"]], ["img-src", ["'self'", "data:", "blob:", ...(opts.imgSrc ?? [])]], ["font-src", ["'self'", "data:", ...(opts.fontSrc ?? [])]], ["connect-src", ["'self'", ...(opts.connectSrc ?? [])]], ["frame-src", [...(opts.frameSrc ?? ["'none'"])]], ["frame-ancestors", ["'none'"]], ["form-action", ["'self'", ...(opts.formAction ?? [])]], ["base-uri", ["'self'"]], ["object-src", ["'none'"]], ["worker-src", ["'self'", "blob:"]], ["manifest-src", ["'self'"]], ["upgrade-insecure-requests", []], ]; if (opts.reportUri) directives.push(["report-uri", [opts.reportUri]]); return directives .map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name)) .join("; "); } export function securityHeaders(opts: SecurityHeaderOptions = {}): HeaderRule[] { const hstsMaxAge = opts.hstsMaxAge ?? DEFAULT_HSTS_MAX_AGE; return [ { key: "Strict-Transport-Security", value: `max-age=${hstsMaxAge}; 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=()", "usb=()", "magnetometer=()", "gyroscope=()", "accelerometer=()", "interest-cohort=()", ].join(", "), }, { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, { key: "X-DNS-Prefetch-Control", value: "on" }, { key: opts.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy", value: contentSecurityPolicy(opts), }, ]; } export const IMMUTABLE_ASSET_EXTENSIONS = [ "ico", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "woff", "woff2", "ttf", "otf", ] as const; /** * Cache rules. Static assets get a year and `immutable`, which is only safe * because every asset URL carries a version query string. The manifest gets a * day. Feeds get an hour with a matching `s-maxage` so the CDN and the browser * agree. */ export function cacheHeaderRules() { return [ { source: `/:path*.(${IMMUTABLE_ASSET_EXTENSIONS.join("|")})`, headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, { source: "/manifest.webmanifest", headers: [ { key: "Cache-Control", value: "public, max-age=86400, s-maxage=86400" }, ], }, { source: "/:path*(feed.xml|rss.xml)", headers: [ { key: "Cache-Control", value: "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", }, ], }, { source: "/:path*(llms.txt|llms-full.txt)", headers: [ { key: "Cache-Control", value: "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", }, { key: "Content-Type", value: "text/plain; charset=utf-8" }, ], }, ]; }X-Content-Type-Options nosniff2/S
Why it matters
Without it a browser may execute a file the server labelled as text, which turns a file upload into a script injection.
How to verify
curl -sI https://example.com | grep -i x-content-type-options.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Framing denied through both X-Frame-Options and frame-ancestors2/S
Why it matters
X-Frame-Options is the legacy header and frame-ancestors is the modern one. Older browsers read only the first, newer ones prefer the second.
How to verify
curl -sI https://example.com | grep -iE 'x-frame-options|frame-ancestors'.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Referrer-Policy set to strict-origin-when-cross-origin2/S
Why it matters
The default leaks full URLs, including query strings that may carry tokens, to every third party a page touches.
How to verify
curl -sI https://example.com | grep -i referrer-policy.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Permissions-Policy denying camera, microphone, geolocation and payment1/S
Why it matters
It costs one header and it removes an entire class of prompt from any third-party script or embedded frame.
How to verify
curl -sI https://example.com | grep -i permissions-policy.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.A real Content-Security-Policy, with the compromises documented3/L
Why it matters
A missing CSP is the most common gap on otherwise well-built sites. A strict script-src on a statically prerendered app requires a nonce, which forces dynamic rendering, so the honest answer is a locked-down policy everywhere else plus a written note about the tradeoff.
How to verify
curl -sI https://example.com | grep -i content-security-policyThen confirm object-src none, base-uri self, frame-ancestors none and a constrained form-action.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Secret comparison is timing-safe, with a deliberate delay on failure3/S
Why it matters
A plain equality check returns as soon as two bytes differ, which leaks the length of the matching prefix to anyone who can measure response time across many requests.
How to verify
Send 100 requests with a wrong token and 100 with a correct-prefix token and confirm the response time distributions overlap.
Implementation snippet
The tested implementation from kit/seo/indexnow.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Every write endpoint runs the same validation pipeline in the same order4/M
Why it matters
Origin, content type, length, rate limit, schema parse, sanitise. Hand-rolling a subset per route is how one endpoint ends up without a rate limit.
How to verify
Post to any write endpoint with a foreign Origin header and confirm a 403 before any parsing happens.
Every interactive element is keyboard reachable with a visible focus ring3/M
Why it matters
A checklist app that cannot be operated from the keyboard is unusable for part of the audience and fails an automated audit immediately.
How to verify
Tab through the whole page without touching the mouse. Every actionable element must receive focus, in visual order, with a visible ring.
Semantic landmarks and a skip link as the first focusable element2/S
Why it matters
Without a skip link a keyboard user tabs through 30 navigation items on every page. Landmarks are how a screen reader user jumps straight to the content.
How to verify
Load the page, press Tab once, and confirm the first focused element is a visible skip link pointing at the main landmark.
16px minimum font size on inputs and 44px minimum tap targets3/S
Why it matters
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.
How to verify
Open the site on a real iPhone, tap a form field, and confirm the page does not zoom. Then confirm every button is at least 44px tall.
No horizontal page scroll at 375px, with code blocks scrolling inside their own container3/M
Why it matters
A code-heavy site is the most likely kind to break this, and a horizontally scrolling body makes a page feel broken even when nothing is.
How to verify
At 375px width run `document.documentElement.scrollWidth > document.documentElement.clientWidth` in the console and confirm it is false on every template.
Terms, privacy, licence and refund pages exist, are linked sitewide, and name real processors2/M
Why it matters
A privacy policy that does not name the processors it uses is not a privacy policy. These pages are also a trust signal on a site asking for money.
How to verify
Confirm each page is reachable from the footer of every page and that the privacy policy names every third party that receives data.
The consent banner genuinely prevents analytics from loading before consent2/M
Why it matters
A banner that appears after the tracker has already initialised is decorative. It claims compliance it does not deliver, which is worse than having no banner.
How to verify
Open the site in a fresh profile, check the network tab before answering the banner, and confirm zero requests to the analytics host.