SEO for Solos

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.

14
Checks in group
2
Free snippets
2.4
Average impact
of 5
9
Position
of 12 groups

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" },
          ],
        },
      ];
    }
    sec-hstsImpact 2/5SmallFreeRead the chapter
  • 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.
    sec-nosniffImpact 2/5SmallRead the chapter
  • 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.
    sec-frame-denyImpact 2/5SmallRead the chapter
  • 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.
    sec-referrer-policyImpact 2/5SmallRead the chapter
  • 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.
    sec-permissions-policyImpact 1/5SmallRead the chapter
  • 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-policy

    Then 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.
    sec-cspImpact 3/5LargeRead the chapter
  • 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.
    sec-timing-safe-compareImpact 3/5SmallRead the chapter
  • 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.

    sec-api-validation-pipelineImpact 4/5MediumRead the chapter
  • 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.

    a11y-keyboard-operableImpact 3/5MediumRead the chapter
  • 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.

    a11y-touch-targetsImpact 3/5SmallFreeRead the chapter
  • 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.

    a11y-no-horizontal-scrollImpact 3/5MediumRead the chapter