Securing Headless CMS Preview Endpoints with JWT Tokens

Preview endpoints are often the weakest link in a content pipeline. Draft data exposed through query parameters, static secrets, or unprotected API routes risks premature indexing, scraping, and edge cache poisoning. Signed, time-bound JWTs close these vectors without slowing down editorial work.

Why Query-String Secrets Fail

The legacy pattern is a ?preview=true flag plus a shared static secret on the URL. Simple, but it fails predictably in production:

  1. Edge cache leakage. CDNs key cache on the URL path; ?preview=true is often a distinct cache key, so draft content can be served to anonymous visitors until you explicitly purge.
  2. Log exposure. Access logs, analytics scripts, and the Referer header capture the full query string. Once logged, the static secret is permanently readable by anyone with log access.
  3. Unbounded scope and lifetime. Static tokens have no expiry and no scoping. One leaked URL grants indefinite read access to every draft endpoint under that secret, with no way to revoke it.

Cryptographic tokens decouple auth from the URL. Within the broader Preview & Draft Workflow Patterns, signed payloads are stateless, auditable, and self-invalidating on expiry — no database lookup or manual purge.

Edge-First Validation

Validate at the edge before any CMS data is fetched or rendered, under 10ms so you don’t move TTFB:

flowchart TD
  A["Editor starts preview"] --> B["Serverless fn mints short-lived JWT"]
  B --> C["Middleware intercepts preview route"]
  C --> D{"Signature + iss/aud/exp valid?"}
  D -->|no| E["401 / 403, delete cookie"]
  D -->|yes| F["Attach claims to request headers"]
  F --> G["Serve draft payload"]
  G --> H["Strip token from request context"]
  1. Trigger. Editor starts preview from the CMS UI or a webhook.
  2. Issuance. A serverless function mints a short-lived JWT with draft metadata and claims.
  3. Interception. Routing middleware verifies the signature and temporal claims on the preview route.
  4. Routing. Valid requests get draft payloads; invalid or expired ones get 401 or 403.
  5. Cleanup. The token is cleared from request context to prevent client-side persistence.

This puts cryptographic verification at the routing layer instead of fragile app-level checks, in line with Token-Based Preview Authentication — unauthorized requests never reach the CMS or database.

Token Generation at the Source

Generate tokens server-side with an edge-compatible crypto library. jose is the standard for modern JS runtimes: RFC-compliant signing, no native bindings.

TypeScript
import { SignJWT } from 'jose';
import { nanoid } from 'nanoid';

export async function generatePreviewToken(draftId: string, locale: string = 'en') {
  const secret = new TextEncoder().encode(process.env.PREVIEW_JWT_SECRET);
  
  const payload = {
    sub: `draft:${draftId}`,
    jti: nanoid(),
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 1800, // 30-minute TTL
    scope: ['preview:read'],
    draft_state: 'unpublished',
    locale,
    aud: 'headless-frontend',
    iss: 'cms-preview-service'
  };

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
    .sign(secret);
}

Notes:

  • Algorithm. HS256 suffices for symmetric edge validation. On multi-tenant platforms use RS256 to separate signing from verification.
  • Payload. Include only routing/authorization claims. No PII, no full document payloads.
  • Lifetime. A 15–30 minute exp absorbs minor clock drift without widening the attack surface.

Middleware Verification

Next.js, Remix, and Astro all expose middleware hooks that run before route rendering. Verification must be strict — reject malformed tokens, expired claims, and mismatched audiences.

TypeScript
import { jwtVerify } from 'jose';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
  const token = req.cookies.get('preview_token')?.value;
  const secret = new TextEncoder().encode(process.env.PREVIEW_JWT_SECRET);

  if (!token) {
    return NextResponse.redirect(new URL('/401', req.url));
  }

  try {
    const { payload } = await jwtVerify(token, secret, {
      algorithms: ['HS256'],
      issuer: 'cms-preview-service',
      audience: 'headless-frontend',
      clockTolerance: 15 // seconds
    });

    // Attach validated claims to request headers for downstream handlers
    const requestHeaders = new Headers(req.headers);
    requestHeaders.set('x-draft-id', payload.sub as string);
    requestHeaders.set('x-preview-locale', payload.locale as string);

    return NextResponse.next({ request: { headers: requestHeaders } });
  } catch (err) {
    // Token expired, tampered, or invalid signature
    const response = NextResponse.redirect(new URL('/403', req.url));
    response.cookies.delete('preview_token');
    return response;
  }
}

export const config = {
  matcher: ['/preview/:path*', '/api/draft/:path*']
};

Notes:

  • Validate iss and aud to block token reuse across unrelated services.
  • Keep clockTolerance small; excess tolerance undermines the time-bound model.
  • Strip the token from response headers and cookies right after validation.

Transport & Client-Side Hygiene

How the token reaches the frontend sets its exposure surface. Query strings and localStorage are out. Instead:

  1. HttpOnly, Secure cookies. Deliver via Set-Cookie with HttpOnly; Secure; SameSite=Lax to block JavaScript access and XSS theft.
  2. Session binding. If your infrastructure allows, bind the token to the editor’s session ID or IP hash for a second validation layer without losing statelessness.
  3. Cache-Control. Return Cache-Control: private, no-store, max-age=0 on preview routes so authenticated responses never hit the edge cache.

For the full pitfall list, see JWT Best Current Practices (RFC 8725).

Key Rotation

Static signing secrets degrade JWT security. Rotate them:

  • Automated rotation. Use IaC or cloud KMS to rotate PREVIEW_JWT_SECRET every 30–90 days. Keep a 24-hour overlap where both old and new secrets verify.
  • Audit logging. Log jti, sub, and validation outcome to a SIEM for fast forensics if a token leaks.
  • Graceful degradation. If the signing service goes down, a circuit breaker should fall back to maintenance mode, never to unauthenticated draft endpoints.

Edge verification adds about 2–4ms per request — negligible against the cache-invalidation storms and data exposure it prevents.

Summary

Shift from URL-bound secrets to verifiable tokens: mint short-lived JWTs at the source, validate at the edge, and enforce strict transport hygiene. The pattern holds across multi-region deployments, supports granular audit trails, and fits zero-trust principles.