Securing Draft Endpoints with JWT in Sanity
When exposing unpublished content for live previews, developers must isolate draft queries from production datasets. Standard Sanity API tokens lack granular expiration controls and audience scoping. They cannot safely validate transient preview sessions. Securing draft endpoints with JWT (JSON Web Token) in Sanity requires strict token validation before any GROQ (Graph-Relational Object Queries) execution. This guide maps the exact implementation path for Next.js 14+ App Router environments.
Token Lifecycle and Preview Routing
A robust Preview & Draft Workflow Implementation relies on HTTP-only cookies to prevent client-side token leakage. The token must carry explicit claims for audience, expiration, and draft scope. The request flow begins in Sanity Studio. A webhook triggers a Next.js API route. The route mints a signed token and sets it as a secure cookie. Subsequent preview requests read the cookie, verify the signature, and execute draft queries.
DX Tradeoff: HTTP-only cookies improve security but complicate cross-domain local development. You will need to configure localhost aliases or use a reverse proxy during active development.
// lib/jwt-verify.ts
import jwt from 'jsonwebtoken';
export function verifyDraftToken(token: string): { valid: boolean; error?: string } {
const secret = process.env.SANITY_DRAFT_SECRET;
if (!secret) throw new Error('Missing SANITY_DRAFT_SECRET');
try {
// CRITICAL: Enforce HS256 to prevent algorithm confusion attacks
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
// Validate expiration explicitly against current epoch
const exp = (decoded as jwt.JwtPayload).exp;
if (!exp || exp < Date.now() / 1000) {
return { valid: false, error: 'Token expired' };
}
return { valid: true };
} catch (err) {
return { valid: false, error: 'Invalid signature or malformed token' };
}
}
Common Failure States and Root Cause Mapping
Developers frequently encounter validation failures when the signing secret rotates. Network inspection often reveals tokens cached in browser storage instead of secure headers. The /api/draft route returns 401 Unauthorized despite a valid Sanity Studio session. Webhooks trigger correctly, but preview fails. The browser network tab shows the draft token leaking in query parameters.
Root causes typically map to four configuration gaps:
- JWT signing secret mismatch between
.env.localand Sanity Studio deployment. - Missing
algorithms: ['HS256']enforcement injwt.verify(). - Token transmitted via URL query instead of
Set-Cookieheader. This causes browser caching and stale preview states. - CORS (Cross-Origin Resource Sharing) wildcard (
*) in Sanity configuration exposing draft endpoints to unauthorized domains.
Step-by-Step Endpoint Hardening
Implementing Draft Mode and Token Authentication requires enforcing SameSite=Lax, verifying exp timestamps, and rejecting unsigned payloads at the edge. Follow these exact steps to patch your Next.js route handlers.
// app/api/draft/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyDraftToken } from '@/lib/jwt-verify';
export async function GET(req: NextRequest) {
const cookieHeader = req.cookies.get('sanity-draft-token');
const token = cookieHeader?.value;
if (!token) {
// CRITICAL: Return 404 to prevent endpoint fingerprinting
return new NextResponse(null, { status: 404 });
}
const { valid, error } = verifyDraftToken(token);
if (!valid) {
return new NextResponse(null, { status: 401 });
}
// Set secure cookie with explicit cache control headers
const response = NextResponse.json({ status: 'preview_active' });
response.cookies.set('sanity-draft-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
// CRITICAL: Prevent browser caching of draft states
response.headers.set('Cache-Control', 'no-store, max-age=0');
return response;
}
DX Tradeoff: Strict Cache-Control: no-store guarantees fresh draft data but increases origin server load. Balance this by implementing stale-while-revalidate for non-preview routes.
Long-Term Security Posture
Prevent endpoint enumeration by returning 404 for invalid claims. Schedule quarterly JWT secret rotation and enforce strict CORS origins in your Sanity configuration. Automate key rotation via CI/CD (Continuous Integration/Continuous Deployment) pipelines on merge to main. Apply rate limiting to /api/draft using edge middleware. A standard threshold is 10 requests per minute per IP address.
Enforce Content-Security-Policy: frame-ancestors 'self' to prevent clickjacking on preview iframes. Verify Sanity webhook payloads using crypto.createHmac('sha256', process.env.SANITY_WEBHOOK_SECRET) before processing. This blocks replay attacks and ensures payload integrity.
Pitfalls
- Edge vs. Serverless Cookie Parsing: Next.js edge runtime lacks full
cookies()support in older versions. Always verify your runtime target matches your cookie parsing library. - Algorithm Confusion: Accepting
RS256alongsideHS256without explicit validation opens the door to public key substitution attacks. Always lock the algorithm array. - Stale Preview States: Browsers aggressively cache
GETrequests. MissingCache-Controlheaders will serve outdated drafts even after token refresh. - Localhost CORS Conflicts: Sanity Studio rejects
http://localhostby default. Addhttp://localhost:3000explicitly to your CORS allowlist during development.
FAQ
Q: Why not store the draft token in localStorage?
A: localStorage is accessible to any client-side script, including third-party analytics. HTTP-only cookies prevent XSS (Cross-Site Scripting) exfiltration. The security boundary must remain server-side.
Q: How do I handle token expiration during long editing sessions?
A: Implement a silent refresh mechanism in your preview layout. Poll a /api/refresh-token endpoint every 15 minutes. Return a new signed cookie only if the original Sanity session remains active.
Q: Can I use this pattern with SSG (Static Site Generation)?
A: Yes, but SSG requires on-demand revalidation. Trigger res.revalidate('/path') after successful token verification. The draft endpoint acts as a gatekeeper for incremental static regeneration.
Q: What happens if the webhook secret and JWT secret differ?
A: Validation will fail at the API layer. Keep secrets synchronized via environment variable injection. Use a single .env source of truth for both Sanity Studio and Next.js deployments.