Signed, Expiring Share Links for External Reviewers
This guide adds one capability to Token-Based Preview Authentication: a share link that lets someone without a CMS account review one draft, for a limited time, with every link recorded and revocable.
Every editorial team eventually needs to show a draft to an outsider. A client approves a campaign page, a lawyer checks a product claim, a partner reviews a co-branded announcement. Without a proper mechanism, editors paste whatever preview URL they have, often one containing a long-lived preview secret, into email threads and chat tools, where it lives forever and grants access to every draft on the site. A share link replaces that with a narrow, auditable credential.
The Problem
An agency preparing a pharmaceutical client’s product launch needs medical-legal review of several pages before publication. The client’s reviewers do not have CMS accounts, and the agency is not allowed to create them. Editors fall back to the CMS’s preview URL, which includes the site’s static preview secret. Weeks after the launch, a security audit finds that secret in a dozen email threads with external addresses, in a project management tool, and in a screenshot attached to a public bug report. Anyone holding any of those links can still read every unpublished draft on the site, including an unannounced product.
What the team needs is simple to state: links that open one draft, for a few days, that the agency can list and revoke, and that make it obvious on screen that the reviewer is looking at a confidential preview.
How Share Links Work
A share link is a signed token like a preview token, with three differences. It is minted deliberately, by an editor who names a recipient and a purpose, instead of automatically on every preview click. It lives longer, typically one to seven days, because reviewers work on their own schedule. And because it lives longer, it must be revocable, which means the frontend checks a revocation list or a link registry on every request.
The claims are narrow: one entry, one locale, one audience, an expiry, a unique id, and the name of the creator. The frontend renders only that entry and its references, never lets the session navigate to other drafts, and marks the page visibly so screenshots cannot be mistaken for published content.
Implementation
The minting endpoint runs on your server and is called from a small CMS extension, such as a sidebar app in Contentful, a document action in Sanity or a custom field in Strapi. The extension authenticates the editor with the CMS’s own app identity, and the endpoint records the link before signing it.
// app/api/share-links/route.ts: called by the CMS extension
import { SignJWT } from "jose";
import { randomUUID } from "node:crypto";
import { db } from "@/lib/db";
import { verifyCmsAppRequest } from "@/lib/cms-app-auth";
interface CreateShareLink {
entryId: string;
locale: string;
recipient: string;
purpose: string;
hours: number;
}
const MAX_HOURS = 168;
export async function POST(req: Request): Promise<Response> {
const editor = await verifyCmsAppRequest(req); // proves the call came from the CMS extension
if (!editor) return new Response("unauthorized", { status: 401 });
const body = (await req.json()) as CreateShareLink;
const hours = Math.min(Math.max(1, body.hours), MAX_HOURS);
const jti = randomUUID();
const exp = Math.floor(Date.now() / 1000) + hours * 3600;
await db.shareLink.create({
data: { jti, entryId: body.entryId, locale: body.locale, recipient: body.recipient, purpose: body.purpose, createdBy: editor.email, expiresAt: new Date(exp * 1000) },
});
const token = await new SignJWT({ sub: `entry:${body.entryId}`, locale: body.locale, kind: "share" })
.setProtectedHeader({ alg: "HS256" })
.setJti(jti)
.setIssuer("share-links")
.setAudience("www.example.com")
.setExpirationTime(exp)
.sign(new TextEncoder().encode(process.env.SHARE_LINK_SECRET));
return Response.json({ url: `https://www.example.com/share/${token}`, expiresAt: exp });
}
// app/share/[token]/page.tsx: renders one draft, watermarked
import { jwtVerify } from "jose";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { fetchDraftEntry } from "@/lib/cms";
import { EntryView } from "@/components/EntryView";
export const dynamic = "force-dynamic";
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
let claims: { sub?: string; locale?: unknown; jti?: string };
try {
({ payload: claims } = await jwtVerify(token, new TextEncoder().encode(process.env.SHARE_LINK_SECRET), {
algorithms: ["HS256"],
issuer: "share-links",
audience: "www.example.com",
}));
} catch {
return <p>This review link has expired or is invalid. Ask the sender for a new link.</p>;
}
const link = await db.shareLink.findUnique({ where: { jti: claims.jti ?? "" } });
if (!link || link.revokedAt) return <p>This review link has been withdrawn.</p>;
await db.shareLinkView.create({ data: { jti: link.jti, viewedAt: new Date() } });
const entry = await fetchDraftEntry(String(claims.sub).replace("entry:", ""), String(claims.locale));
if (!entry) notFound();
return (
<>
<div className="share-watermark" role="note">Confidential draft for review by {link.recipient}. Not published.</div>
<EntryView entry={entry} />
</>
);
}
Send Cache-Control: private, no-store, X-Robots-Tag: noindex and Referrer-Policy: no-referrer for the /share/ path, so the token in the URL is not passed to third parties when the reviewer follows an outbound link. Because the page re-verifies on every request, revoking a link in the registry takes effect on the reviewer’s next page load.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Lifetime | 48 h default, 7 days maximum | Covers review cycles without indefinite access. |
| Scope | one entry and locale | A leaked link reveals one draft. |
| Registry | jti, entry, recipient, purpose, creator, expiry, revokedAt |
Audit trail and revocation. |
Headers on /share/* |
private, no-store, noindex, Referrer-Policy: no-referrer |
No caching, indexing or token leakage via Referer. |
| Watermark | recipient name, “not published” | Screenshots stay identifiable. |
| Secret | separate from the editor preview secret | Rotating one does not break the other. |
Gotchas & Edge Cases
- Links forwarded inside the recipient’s organization. A share link works for whoever holds it. For sensitive material, add a lightweight second factor, such as a one-time code emailed to the named recipient before the first view.
- Reviewers clicking through navigation. The share page must not render live navigation that leads into other drafts. Render the entry in a minimal layout or disable internal links.
- Entries that change after sharing. Reviewers see the latest draft on each visit, which is usually what the editor wants. When approval must refer to a fixed version, include the entry version in the token and render that version.
- Expired links in long email threads. A clear expiry message with the sender’s name avoids confused support requests and nudges reviewers to ask for a fresh link.
- Link previews in chat tools. Chat apps fetch URLs to build previews, which counts as a view and may cache a screenshot. The
noindexheader does not stop them; the watermark and short lifetime limit the damage.
Operational Notes
Give editors a list of their active links in the CMS extension, with one-click revocation, and send a daily digest of links created, viewed and expired to the content lead. Most organizations discover that a handful of links account for most external review traffic, and that nobody had previously known which drafts were being viewed outside the company. After a launch, revoke all links for the launched entries automatically, because once content is published the share link has no purpose left.
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.
- Build the minting endpoint and the CMS extension that calls it with the editor’s identity.
- Store every link in a registry with creator, recipient, purpose and expiry.
- Render share pages in a minimal layout with a visible watermark and strict headers.
- Revoke links automatically when their entry is published or deleted.
- Retire any static preview secret that editors previously shared, and rotate it.
Frequently Asked Questions
Why not create CMS accounts for reviewers instead?
Seats cost money, onboarding takes time, and a CMS account grants far more than one draft. For occasional external review, a scoped link is safer and faster. Frequent collaborators may still deserve accounts with restricted roles.
Can share links work for pages that combine many entries?
Yes: scope the token to the page entry and render its references as drafts too. Keep the scope to that page; do not let the session open other pages.
How do I know whether a reviewer has looked?
The view log records each visit to the share page. Showing “viewed twice, last on Tuesday” next to each link in the CMS extension answers the most common follow-up question editors ask.
Can reviewers leave comments through the share link?
They can, if the share page includes a simple comment form that posts to your server and stores comments against the entry and link id. Keep it separate from the CMS’s own commenting, which requires accounts, and surface the comments to editors in the CMS extension next to the link.
Should the token be in the path or the query string?
Either works, since the token is designed to appear in URLs. A path segment is slightly less likely to be stripped or rewritten by email security tools than a query parameter.