Securing Headless CMS Preview Endpoints with JWT Tokens
Part of Token-Based Preview Authentication, this guide starts from a hard truth: 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:
- Edge cache leakage. CDNs key cache on the URL path;
?preview=trueis often a distinct cache key, so draft content can be served to anonymous visitors until you explicitly purge. - Log exposure. Access logs, analytics scripts, and the
Refererheader capture the full query string. Once logged, the static secret is permanently readable by anyone with log access. - 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:
- Trigger. Editor starts preview from the CMS UI or a webhook.
- Issuance. A serverless function mints a short-lived JWT with draft metadata and claims.
- Interception. Routing middleware verifies the signature and temporal claims on the preview route.
- Routing. Valid requests get draft payloads; invalid or expired ones get
401or403. - 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.
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.
HS256suffices for symmetric edge validation. On multi-tenant platforms useRS256to separate signing from verification. - Payload. Include only routing/authorization claims. No PII, no full document payloads.
- Lifetime. A 15–30 minute
expabsorbs 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.
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
issandaudto block token reuse across unrelated services. - Keep
clockTolerancesmall; 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:
- HttpOnly, Secure cookies. Deliver via
Set-CookiewithHttpOnly; Secure; SameSite=Laxto block JavaScript access and XSS theft. - 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.
- Cache-Control. Return
Cache-Control: private, no-store, max-age=0on 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_SECRETevery 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.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Algorithm | HS256 single service, RS256 or EdDSA when signer and verifier differ |
Asymmetric keys let the frontend verify without being able to mint. |
| Token lifetime | 30 min max; minutes if only used for the redirect | Limits replay after a leak. |
clockTolerance |
15 s | Absorbs minor drift without widening the window. |
algorithms in verify |
explicit allow-list | Blocks algorithm-confusion attacks. |
| Cookie | HttpOnly; Secure; SameSite=Lax (None inside CMS iframes) |
Unreadable by scripts; works in embedded previews. |
| Rotation | 30 to 90 days, 24 h overlap | Bounded blast radius without breaking sessions. |
Gotchas & Edge Cases
- Storing the raw JWT as the session cookie. It works, but the cookie then carries every claim and lives as long as the token. Exchange the token for an opaque session id stored server-side, or re-sign a minimal session token, so the long-lived artifact contains no scope details.
SameSite=Laxinside the CMS iframe. Embedded previews are third-party contexts, where Lax cookies are not sent on subresource requests. UseSameSite=None; Securefor preview cookies that must work in iframes, and scope them to preview paths.- Accepting
alg: noneor unexpected algorithms. Always pass the explicitalgorithmslist to the verify call; never let the token header choose. - Middleware matcher gaps. A draft API route outside the matcher skips verification entirely. Keep all preview and draft routes under one path prefix and match the prefix.
- Logging tokens. Error logs that print the request URL at the validation route leak the token. Log
jtiandsubfrom the verified payload, never the raw token.
Worked Example
A media company’s preview links used ?preview=secret123 for three years. A security review found the secret in the CDN logs shipped to a third-party analytics vendor and in 40 bug tickets where editors had pasted preview URLs. Migrating to minted JWTs took a week: a small minting function called from the CMS preview button, middleware verification with jose, and a cookie exchange. The static secret was retired the same day, and the next leaked preview URL in a ticket was harmless, because its token had expired thirty minutes after it was created.
Frequently Asked Questions
Can the CMS mint tokens itself?
Some CMSs can sign preview URLs natively or through an app or plugin; others call an external function. Either works, provided the signing key never leaves the CMS side and the frontend only holds the verification key or the shared secret.
Is HS256 secure enough?
Yes, with a long random secret known only to the minting service and the verifier. Prefer an asymmetric algorithm when several frontends verify tokens, so none of them can mint.
How do I revoke a token before it expires?
Keep a small revocation list of jti values in a shared store and check it at the validation route. Because tokens are short-lived, the list stays tiny and entries can expire with the tokens.
Does edge verification add noticeable latency?
Verifying an HMAC or EdDSA signature takes well under a millisecond; the measurable cost of edge middleware is its startup, which runs for every matched request anyway. Scope the matcher to preview routes so public pages pay nothing.
Where should the verification key live on serverless platforms?
In the platform’s encrypted environment variables or secret manager, available to the middleware and validation route only. Avoid bundling it into client-side code by keeping it out of variables with public prefixes such as NEXT_PUBLIC_.
Should the token be passed in a header instead of the URL?
For the initial CMS redirect, the URL is the only option, which is why the token must be short-lived and exchanged immediately. For API calls after that, send the session cookie or an Authorization header from server code, never a URL parameter.