Accepted

ADR 0016 — Preview-token signing

Domain-locked, single-use share links for previewing unpublished content

A preview token is a URL-as-credential. An editor generates a share link in the admin, sends it to a reviewer, and the reviewer sees unpublished content rendered exactly as the public site would render it. The token is a signed JWT carried in the ?preview= query parameter. The public render path validates it before any cache lookup, so a preview never serves stale or cached HTML.

The decision

Tokens are HS256 JWTs signed with a per-install secret. No JWT library enters the Worker bundle — signing and verification are hand-rolled against the Web Crypto API, with a constant-time signature comparison. A token that fails any check returns 404, never a descriptive error, so a probing client cannot tell a bad signature from an expired token from a token for a different document.

The signing secret is read from the PREVIEW_TOKEN_SECRET Wrangler secret when set; otherwise it falls back to an install-time secret generated by the setup wizard, so a default deploy works without a manual wrangler secret put. Rotation is a single admin action.

The token carries these claims:

  • iss — always "cusp".
  • sub — the entity the token references (a document id, or a theme id for customise tokens).
  • ctx — the render context: "document", "collection-listing", or "customise".
  • aud — the lower-cased hostname the token is bound to.
  • iat, exp, nbf — issued-at, expiry, and not-before, in Unix seconds. Lifetime is clamped to 30 days.
  • scope — what the token unlocks: "preview:draft", "preview:scheduled", "preview:all", "comment-author", or "customise-preview".
  • jti — a ULID used as the single-use redemption marker.
  • v — the token format version. The value is 2; validators reject any other value so the format can evolve without ambiguity.

Scope follows an "unlocks set" model. A preview:draft token unlocks both draft and published, so a link shared by email keeps working after the document is published. preview:scheduled unlocks scheduled and published; preview:all unlocks draft, scheduled, and published.

Domain-lock

The aud claim pins the token to one hostname. At issuance it is filled from the host the editor's admin session is served on. At validation the Worker computes the request hostname and constant-time-compares it to aud; a mismatch returns 404.

The bound host is the request hostname, not a configured base URL, by design. A deployment reachable at both a workers.dev subdomain and a custom domain has no way to replay a token across the two — a token works only at the host it was issued from, which is the host the editor copied the link from anyway. There is no admin choice to make and no config to author.

Single-use

A token is redeemable at most once. Redemption is a successful render of the previewed document. After redemption the same token returns 404. Single-use is the last check, after signature, expiry, not-before, and audience all pass.

Redemption state lives in KV, keyed by jti, with a TTL set to the token's remaining lifetime. KV — not D1, not the Cache API, not a Durable Object — because the markers expire on their own, have no audit value, and must not pollute the live database with churn. KV's eventual consistency is acceptable: a stale read at most lets a token redeem twice within seconds in the same region, which is strictly less harmful than the no-single-use baseline.

When KV is unavailable the path fails open: the redemption read is treated as not-yet-redeemed, and the marker write is skipped rather than retried. A KV outage that took preview offline would be a worse failure than a temporarily-non-single-use preview — editors send links to colleagues, and "your reviewer cannot open the link because of an unrelated KV blip" is the bigger correctness problem. Signature, expiry, and audience checks still run. Both the degraded read and the skipped write emit a structured warning so the admin Health page can surface that single-use enforcement is degraded.

The comment-author claim

The comment-author scope authorises posting a comment as the previewed document's author, rather than reading the draft. On its own it unlocks no document render — a comment-author token presented to the public render handler is rejected and returns 404. A dedicated validator accepts only this scope and returns the document id and the comment-author user id (carried in the cau claim) on success. The claim is reserved in the wire format so plugin comment widgets can ship against a stable shape without a token-format change.

Consequences

  • Leaked links are bounded. Domain-lock stops cross-host replay; single-use bounds a leaked URL to one redemption regardless of token lifetime.
  • The contract is "one redemption", not "share with one reviewer". A reviewer who refreshes the tab a second time sees 404, so the admin share dialog tells the editor the link works once and offers a one-click regenerate.
  • Multi-host deployments lose one edge case: a single link cannot work at two hostnames. This is rare, and the security gain is the point.
  • Everything stays inside existing bindings — no new D1 table, no new Durable Object. The redemption marker is a single KV key per token, with the bundle delta from the validator negligible.

The customise-preview scope reuses the same token format for the live theme-customise iframe; that path is documented separately.

Read the live-preview decision