Gating Previews Behind SSO and CMS Roles

For organizations where drafts are sensitive, such as financial results, regulated product claims or unannounced launches, Token-Based Preview Authentication alone is not enough: a valid preview link proves the request came from the CMS, but not who is holding it now. This guide adds single sign-on in front of the preview route and enforces the editor’s CMS role per content type, so only the right people can see each draft, from any device, with a full audit trail.

The design keeps both mechanisms and gives each one job. The minted preview token answers “which draft, created by which CMS action, until when”. The SSO session answers “which person is at the keyboard, and are they still employed”. The preview route requires both, and the content types a person may preview come from their role, not from the link.

Two checks before any draft rendersA preview request must carry a valid minted token and an identity-provider session; the route maps the user's groups to permitted content types, and only renders the draft when the token's entry type is permitted.Preview requestMinted tokenentry, expSSO sessionuser, groupsRole mapgroups → content typesRender draftaudit log403request accesspermittednot permitted
The token scopes the draft; the SSO session identifies the person; the role map decides whether they meet.

The Problem

A listed company prepares quarterly results pages in its CMS weeks ahead of publication. Preview links, even minted and short-lived ones, travel: editors forward them to colleagues for a quick look, open them on personal phones, and paste them into meeting chats where external participants are present. Compliance requires that only members of the investor relations and legal groups can see results drafts before release, that access is logged by person, and that access ends the moment someone leaves the company. Links alone cannot meet any of those requirements.

How the Combination Works

The frontend becomes an OpenID Connect client of the company’s identity provider for its preview routes. The first time an editor opens a preview link, the route checks for an SSO session; without one, it redirects to the identity provider, which authenticates the person with the company’s normal policy (multi-factor authentication, device checks) and returns them with an ID token that includes their groups. The route stores a short session, then validates the preview token as usual.

Authorization joins the two. A small role map in configuration lists which identity groups may preview which content types, for example investor-relations: [resultsPage, pressRelease]. The preview token names the entry’s content type, and the route renders the draft only if the person’s groups permit that type. Everything else, the cookie handling, the no-store headers and the fetch helper, stays exactly as in the base topic.

First preview with SSOThe editor opens a preview link; the route finds no SSO session and redirects to the identity provider; after sign-in the provider returns an ID token with groups; the route checks the role map and the preview token, then renders the draft.EditorPreview routeIdentity providerCMS draft APIGET /preview?token=…302 to IdP (no session)sign in, MFAcallback: ID token (groups)role map + preview tokenboth validfetch draft (server token)draft, no-store
The identity provider is visited once per session; later preview links go straight to the draft.

Implementation

The example uses Auth.js (NextAuth) with an OpenID Connect provider for the SSO part and the preview token verifier from the base topic. Only preview routes require sign-in; public pages are untouched.

TypeScript
// auth.ts: SSO for preview routes only
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";

export const config: NextAuthConfig = {
  providers: [
    {
      id: "corp",
      name: "Company SSO",
      type: "oidc",
      issuer: process.env.SSO_ISSUER,
      clientId: process.env.SSO_CLIENT_ID,
      clientSecret: process.env.SSO_CLIENT_SECRET,
      authorization: { params: { scope: "openid email profile groups" } },
    },
  ],
  session: { strategy: "jwt", maxAge: 8 * 60 * 60 },
  callbacks: {
    jwt({ token, profile }) {
      if (profile) token.groups = (profile as { groups?: string[] }).groups ?? [];
      return token;
    },
    session({ session, token }) {
      (session as unknown as { groups: string[] }).groups = (token.groups as string[]) ?? [];
      return session;
    },
  },
};

export const { auth, handlers, signIn } = NextAuth(config);

// lib/preview-access.ts: role map decides which drafts a person may see
const ROLE_MAP: Record<string, string[]> = {
  "investor-relations": ["resultsPage", "pressRelease"],
  legal: ["resultsPage", "pressRelease", "legalNotice"],
  marketing: ["landingPage", "article", "pressRelease"],
};

export function mayPreview(groups: string[], contentType: string): boolean {
  return groups.some((g) => ROLE_MAP[g]?.includes(contentType));
}

// app/preview/[entryId]/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { verifyPreviewToken } from "@/lib/preview-keys";
import { mayPreview } from "@/lib/preview-access";
import { fetchDraftEntry } from "@/lib/cms";
import { auditPreview } from "@/lib/audit";

export const dynamic = "force-dynamic";

export default async function PreviewPage({ params, searchParams }: { params: Promise<{ entryId: string }>; searchParams: Promise<{ token?: string }> }) {
  const { entryId } = await params;
  const { token } = await searchParams;
  const session = await auth();
  if (!session?.user?.email) redirect(`/api/auth/signin?callbackUrl=${encodeURIComponent(`/preview/${entryId}?token=${token ?? ""}`)}`);

  const claims = await verifyPreviewToken(token ?? "").catch(() => null);
  if (!claims || claims.sub !== `entry:${entryId}`) return <p>This preview link is invalid or has expired.</p>;

  const groups = (session as unknown as { groups: string[] }).groups;
  const contentType = String(claims.type);
  if (!mayPreview(groups, contentType)) {
    await auditPreview({ user: session.user.email, entryId, contentType, allowed: false });
    return <p>You do not have access to drafts of this type. Ask the content owner to request access.</p>;
  }

  await auditPreview({ user: session.user.email, entryId, contentType, allowed: true });
  const entry = await fetchDraftEntry(entryId);
  return <DraftView entry={entry} />;
}

The minting service puts the content type into the token’s type claim, since it already knows the entry. DraftView and fetchDraftEntry are the same components and helper used for ordinary previews. The ID token’s groups come from the identity provider’s group claims, which usually need to be enabled for the application in the provider’s settings.

Configuration Reference

Setting Value Why
SSO scope preview routes only Public pages never redirect readers to a login.
Session lifetime a working day (8 h) Editors sign in once per day; the IdP enforces offboarding.
Group claims enabled for the app in the IdP Roles come from the directory, not from the CMS link.
Role map groups to content types, in code review Access changes are reviewed like code.
Preview token still required Scopes the draft; SSO alone would expose every draft to every permitted user.
Audit log user, entry, type, allowed, time Answers “who saw this draft” per person.

Gotchas & Edge Cases

  • Previews inside the CMS iframe. Identity providers often refuse to render their login page in an iframe. When the preview runs inside the CMS, start the sign-in in a popup or a top-level tab, then return to the iframe once the session cookie exists.
  • Third-party cookie restrictions. The SSO session cookie is third-party inside the CMS iframe and may be partitioned or blocked. Test with the browsers editors use, and fall back to opening previews in a new tab where necessary.
  • Group sprawl. Mapping many directory groups to content types quickly becomes unreadable. Create a small number of preview-specific groups in the directory and map only those.
  • External reviewers. People outside the directory cannot use SSO. Keep share links for them, restricted by the role map to content types that may be shared at all.
  • Service accounts and tests. End-to-end tests need to pass SSO. Use a dedicated test user in a test tenant, or a test-only bypass that is compiled out of production builds, never a hidden query parameter.

Operational Notes

The audit log is what compliance teams actually ask for, so make it useful. Store the person, the entry, the content type, the decision and the time; export it on request; and review denied requests weekly, because a denial is usually either a missing group membership or a link that travelled somewhere it should not have. Offboarding becomes automatic: when a person is removed from the directory, their next preview request fails at the identity provider, without anyone touching the CMS or the frontend.

Which layer answers which compliance questionCompliance questions about draft access mapped to the component that answers them: the preview token, the SSO session, the role map or the audit log.QuestionAnswered byWhich draft could this link open?preview token scopeWho opened it?SSO session identityWere they allowed to?role mapWhen, and how often?audit logDoes access end when they leave?identity provider offboarding
No single layer answers every question; together they cover the ones auditors ask.

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.

  • Register the frontend’s preview routes as an OIDC application with group claims.
  • Create preview-specific directory groups and write the role map.
  • Add the content type to minted preview tokens.
  • Require both the SSO session and the preview token on every preview route.
  • Log every allowed and denied preview and review denials weekly.
  • Test the iframe sign-in flow in the browsers editors use.

Frequently Asked Questions

Can SSO replace preview tokens entirely?

It could, if the route simply shows any draft to any authorized person. Keeping tokens means a person only sees the draft someone deliberately opened or shared, which is a useful second boundary for sensitive content.

Does this work with CMS roles instead of directory groups?

If the CMS itself uses SSO and exposes roles in tokens or through an API, the minting service can include the editor’s CMS role in the preview token. Directory groups are usually preferable because they are managed centrally and change immediately on offboarding.

Will editors have to sign in constantly?

Once per session, typically once per working day. Subsequent preview links go straight to the draft while the SSO session is valid.

Does SSO slow down preview for editors?

Only on the first preview of a session, when the identity provider redirect adds a second or two, often without any prompt because the editor is already signed in to the provider. Every later preview request checks a local session cookie, which costs nothing noticeable.

What happens to open previews when someone is removed from a group?

The group change takes effect at the next sign-in, which the session lifetime bounds. For immediate effect on sensitive content, shorten the session lifetime for that content type’s routes, or check group membership against the directory on each request.