Rotating Preview Secrets Without Breaking Editor Sessions
Every credential in Token-Based Preview Authentication eventually needs replacing, either on a schedule or in a hurry after a leak. This guide rotates the preview signing key and the CMS preview API token in a way that editors never notice, using key ids, a verification set with more than one key, and a planned overlap window.
Rotation fails in two opposite ways. Teams that rotate by simply replacing the secret break every open preview session and every share link at once, and editors learn to fear rotations, so they stop happening. Teams that never rotate keep a secret that has passed through a dozen laptops, CI logs and former colleagues. The goal is rotation that is routine, scripted and invisible.
The Problem
A retailer’s preview setup used one PREVIEW_SECRET, shared by the CMS preview URL configuration and the Next.js validation route. When an engineer who had the secret in a local .env file left the company, security asked for a rotation. The team changed the variable in the hosting dashboard and redeployed. Within minutes, every editor with an open preview got a 401, all 60 active reviewer share links stopped working, and the CMS preview button kept sending the old secret for another hour, until someone found the second place where it was configured. The rotation was reverted, and the old secret stayed in use for another eight months.
How Graceful Rotation Works
Three ingredients make rotation boring:
- Key ids. Every token names the key that signed it in the
kidheader. The verifier does not guess; it looks up the key. - A verification set. The verifier holds the current key and, during a rotation window, the previous one. It rejects tokens whose
kidis not in the set. - A sequence. Add the new key to the verification set first, then switch the signer to the new key, then remove the old key after the longest token or session lifetime has passed.
With asymmetric keys, the verification set is a JSON Web Key Set that the minting service publishes and the frontend fetches and caches, which makes rotation a change on the minting side only. With symmetric secrets, the set is a small map in environment configuration.
Implementation
The verifier below accepts a map of key ids to secrets from an environment variable and verifies each token with the key its header names. It works unchanged across rotations; only configuration changes.
// lib/preview-keys.ts
import { decodeProtectedHeader, jwtVerify, SignJWT } from "jose";
import type { JWTPayload } from "jose";
// PREVIEW_KEYS='{"2026-06":"base64secretA","2026-09":"base64secretB"}'
// PREVIEW_SIGNING_KID=2026-09
const keys: Record<string, Uint8Array> = Object.fromEntries(
Object.entries(JSON.parse(process.env.PREVIEW_KEYS ?? "{}") as Record<string, string>).map(([kid, b64]) => [kid, Buffer.from(b64, "base64")]),
);
export async function signPreviewToken(claims: JWTPayload, ttlSeconds: number): Promise<string> {
const kid = process.env.PREVIEW_SIGNING_KID ?? "";
const key = keys[kid];
if (!key) throw new Error(`Signing key ${kid} is not configured`);
return new SignJWT(claims)
.setProtectedHeader({ alg: "HS256", kid })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)
.setIssuer("cms-preview-service")
.setAudience("www.example.com")
.sign(key);
}
export async function verifyPreviewToken(token: string): Promise<JWTPayload> {
const { kid, alg } = decodeProtectedHeader(token);
if (alg !== "HS256" || !kid || !keys[kid]) throw new Error("unknown key id");
const { payload } = await jwtVerify(token, keys[kid], {
algorithms: ["HS256"],
issuer: "cms-preview-service",
audience: "www.example.com",
clockTolerance: 15,
});
return payload;
}
A rotation is then a configuration change in three steps, each deployable on its own:
# Step 1 (day 0): add the new key everywhere that verifies
PREVIEW_KEYS='{"2026-06":"<old>","2026-09":"<new>"}'
PREVIEW_SIGNING_KID=2026-06
# Step 2 (day 1): switch the signer; old tokens still verify
PREVIEW_SIGNING_KID=2026-09
# Step 3 (day 8): remove the old key after the longest credential lifetime
PREVIEW_KEYS='{"2026-09":"<new>"}'
The CMS preview API token, the credential the frontend server uses to fetch drafts, rotates with the same overlap idea but on the CMS side: create a second token in the CMS, deploy it to the frontend, confirm draft fetches succeed with it, then delete the old token in the CMS. Most CMSs allow several active API tokens at once, which is what makes this possible without downtime.
Configuration Reference
| Item | Schedule | Overlap | Notes |
|---|---|---|---|
| Preview signing key | every 90 days | session lifetime (30 to 60 min) | Key ids in token headers. |
| Share link key | every 90 days | longest link lifetime (up to 7 days) | Separate from the preview key. |
| CMS preview API token | every 90 days | minutes | Create new, deploy, verify, delete old. |
| Webhook signing secret | every 180 days | CMS-dependent | Accept both signatures during the switch where the CMS supports it. |
| Emergency rotation | immediately | zero | Accept broken sessions; notify editors. |
Gotchas & Edge Cases
- A secret configured in two places. CMS preview URLs, minting functions and validation routes may each hold a copy. Keep one source of truth, such as a secret manager, and inject it everywhere, or list every location in the runbook.
- Emergency rotations. After a confirmed leak, skip the overlap: remove the compromised key from the verification set immediately and accept that sessions and share links break. Tell editors in advance what they will see.
- Caching of the key set. Frontends that fetch a JWKS must refresh it, typically every few minutes and on encountering an unknown
kid. A stale cache turns a routine rotation into an outage. - Tokens without
kid. During migration to key ids, old tokens have none. Treat a missingkidas the legacy key for one overlap window, then reject it. - Rotating webhook secrets. Webhook signatures cannot carry a key id in every CMS. Verify against both secrets during the switch, and log which one matched, so you know when the old one can be removed.
Operational Notes
Automate the schedule. A small job that creates the new secret in the secret manager, opens a change for step 1, waits a day, performs step 2 and a week later performs step 3 turns rotation from a project into a background process. Record each rotation, with the key ids and dates, in the same log that holds preview session starts, so an investigation can tell which key signed a given token. Most importantly, rehearse the emergency path once, in staging, so the one time it is needed it is a known procedure rather than an improvisation under pressure.
Worked Example
The retailer from the problem statement tried again with the staged approach. First, the minting function and the validation route moved to reading keys from the secret manager, and key ids were added to tokens, which for one overlap window meant accepting tokens without a kid as the legacy key. Then the rotation ran as three separate deploys over eight days. Nobody outside the platform team noticed: no editor lost a preview session, and every reviewer’s share link kept working until it expired. The quarterly rotation has since run automatically four times, and the runbook for the emergency path was used once, after a laptop theft, when the team deliberately accepted a few broken sessions in exchange for immediate revocation.
Rollout Checklist
Work through these steps in order; each one is small, can ship on its own, and leaves the preview in a safer state than before, so there is no need to wait for the whole list before deploying the first item.
- Add key ids to every token the minting service signs.
- Change the verifier to look keys up by id from a configured set.
- Move all copies of preview secrets into one secret manager.
- Script the three-step rotation and run it once in staging.
- Document the emergency path and who approves it.
Frequently Asked Questions
How often should preview secrets rotate?
Every 90 days is a common default, plus whenever someone with access leaves or a leak is suspected. With scripted, overlapping rotation, the frequency matters less than the fact that rotation is easy enough to actually happen.
Do asymmetric keys make rotation easier?
Yes. The frontend fetches public keys from a key set published by the minting service, so rotation happens entirely on the minting side: publish the new public key, sign with the new private key, retire the old one.
What about preview secrets embedded in CMS preview URLs?
Replace them with minted tokens first, because a static secret in a CMS URL template cannot carry a key id and tends to be copied into many places. Once previews use minted tokens, the CMS no longer holds a verification secret at all.
Should I rotate after every staff change?
Rotate when someone who had direct access to the secret leaves. If secrets live only in a secret manager and nobody copies them to laptops, most departures do not require a rotation, which is another reason to keep them there.