Authenticating Live Preview WebSocket and SSE Channels

This guide closes a gap in Token-Based Preview Authentication that live editing opens: the page request is carefully authenticated, but the WebSocket or Server-Sent Events channel that streams draft updates to it often is not. Anyone who can guess the channel URL and an entry id can then subscribe to drafts as editors type.

Streaming channels are easy to leave open for three reasons. Browsers cannot set custom headers on WebSocket connections, so the usual Authorization header is unavailable. Many examples pass the preview token in the channel URL, where it ends up in proxy logs. And a stream stays open for hours, far longer than the token that authorized it. Each needs a specific fix.

Ticket-based WebSocket authenticationThe preview page, already authenticated by its session cookie, asks the server for a short-lived single-use ticket scoped to one entry; it opens the WebSocket with the ticket; the socket server redeems the ticket, subscribes the connection to that entry only, and closes it when the session expires.Preview pageTicket endpointSocket serverDraft update busPOST /api/preview/ticket (cookie)session valid?scope: entry 7Ht2ticket (30 s, single use)connect wss://…/live?ticket=…redeem ticketcheck Originupdate entry 7Ht2patchsession expiry → close 4401
The long-lived session never appears in a URL; the ticket that does is valid for seconds and only once.

The Problem

A publisher’s live preview used a WebSocket server that accepted wss://live.example.com/drafts/<entryId> and broadcast updates for that entry. The entry ids were visible in public page markup for published articles, and drafts of new articles used sequential ids. A curious reader who opened the browser’s developer tools found the socket URL in the preview script, tried a few ids, and watched an unannounced article being written in real time. Nothing in the setup was exotic: the page route was token-gated, but the socket server had been written as “internal” and nobody had added authentication.

How Channel Authentication Works

Three rules make a live channel as safe as the page that uses it:

  1. Authenticate the connection with a ticket, not the session. The page, already authenticated by its httpOnly session cookie, asks a server endpoint for a ticket: a random or signed value valid for a few seconds, redeemable once, and scoped to the entries the session may see. The page opens the socket with the ticket as a query parameter. A ticket leaked in a log is worthless seconds later.
  2. Authorize every subscription on the server. The socket server subscribes the connection only to the entries named in the ticket. Clients never choose their own topics.
  3. Bound the connection’s lifetime by the session’s. When the session expires or is revoked, the server closes the socket with an application close code that tells the page to re-authenticate.

Server-Sent Events are simpler because an EventSource sends cookies automatically for same-origin URLs. An SSE endpoint on the same origin as the preview page can therefore check the preview session cookie directly, with no ticket, as long as it also verifies the scope and closes the stream at expiry.

Authenticating each transportWebSocket and Server-Sent Events compared on how credentials reach the server, where they can leak, and the recommended pattern.TransportCredentials sentLeak riskRecommendedWebSocket, same origincookies on handshakeCSWSH without Origin checkcookie + Origin check, or ticketWebSocket, other originquery string onlytoken in logssingle-use ticket, 30 sSSE, same origincookies automaticallylowsession cookie + scope checkSSE, other origincookies only with CORS credentialsCORS mistakesproxy through same origin
SSE can reuse the session cookie on the same origin; WebSockets need a short-lived ticket.

Implementation

The ticket endpoint and the socket server below use a shared store such as Redis for tickets. The page code is a few lines around new WebSocket.

TypeScript
// app/api/preview/ticket/route.ts: issue a single-use ticket for the current preview session
import { randomBytes } from "node:crypto";
import { redis } from "@/lib/redis";
import { readPreviewSession } from "@/lib/preview-session";

export async function POST(): Promise<Response> {
  const session = await readPreviewSession(); // validated httpOnly cookie
  if (!session) return new Response("unauthorized", { status: 401 });
  const ticket = randomBytes(24).toString("base64url");
  await redis.set(
    `ticket:${ticket}`,
    JSON.stringify({ entries: session.entries, sessionId: session.id, expiresAt: session.expiresAt }),
    "EX",
    30,
  );
  return Response.json({ ticket });
}

// live-server.ts: Node WebSocket server (ws package)
import { WebSocketServer } from "ws";
import type { WebSocket } from "ws";
import { redis, subscriber } from "./redis";

const ALLOWED_ORIGINS = new Set(["https://www.example.com", "https://app.contentful.com"]);
const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", async (socket: WebSocket, req) => {
  if (!ALLOWED_ORIGINS.has(req.headers.origin ?? "")) return socket.close(4403, "origin");
  const ticket = new URL(req.url ?? "", "http://x").searchParams.get("ticket") ?? "";
  // GETDEL makes the ticket single-use.
  const raw = await redis.getdel(`ticket:${ticket}`);
  if (!raw) return socket.close(4401, "ticket");
  const grant = JSON.parse(raw) as { entries: string[]; sessionId: string; expiresAt: number };

  const channels = grant.entries.map((id) => `draft:${id}`);
  const onMessage = (channel: string, message: string) => {
    if (channels.includes(channel)) socket.send(message);
  };
  await subscriber.subscribe(...channels);
  subscriber.on("message", onMessage);

  // Close when the preview session ends, or when it is revoked.
  const expiryTimer = setTimeout(() => socket.close(4401, "session expired"), Math.max(0, grant.expiresAt - Date.now()));
  const revokeCheck = setInterval(async () => {
    if (await redis.sismember("revoked-sessions", grant.sessionId)) socket.close(4401, "session revoked");
  }, 15_000);

  socket.on("close", () => {
    clearTimeout(expiryTimer);
    clearInterval(revokeCheck);
    subscriber.off("message", onMessage);
  });
});

On the page, fetch a ticket, connect, and on close code 4401 fetch a new ticket or send the editor back through preview authentication. Treat any other close code as a network problem and reconnect with backoff, with a fresh ticket each time.

Configuration Reference

Setting Value Why
Ticket lifetime 30 s Long enough to open the socket, short enough to be useless in logs.
Ticket use single use (GETDEL) Replays fail even within the lifetime.
Ticket scope entries of the preview session Subscriptions are decided by the server.
Origin check preview site and CMS studio origins Blocks cross-site WebSocket hijacking.
Close codes 4401 re-authenticate, 4403 forbidden The page can react correctly to each.
Revocation poll 15 s Revoked sessions lose their stream quickly.

Gotchas & Edge Cases

  • Cross-site WebSocket hijacking. Browsers send cookies on WebSocket handshakes from any site. A socket that authenticates by cookie alone can be opened by a malicious page in the editor’s browser. Always check the Origin header, even when using cookies.
  • Tokens in query strings. Passing the preview JWT itself as a socket parameter puts a 30-minute credential into proxy logs. Tickets exist to avoid exactly that.
  • Broadcasting full documents. Send only the fields that changed, and only for subscribed entries. A channel that broadcasts every draft update to every connection leaks content between editors with different roles.
  • Horizontal scaling. With several socket servers, tickets and revocations must live in a shared store, and draft updates must reach every server through pub/sub, as in the example.
  • SSE through CDNs. Some CDNs buffer event streams. Serve SSE from a path that bypasses caching and buffering, with Cache-Control: no-store and X-Accel-Buffering: no where applicable.

Operational Notes

Log connection attempts with their outcome and close code, but never the ticket. A burst of 4401 closes usually means an expired session configuration or clock skew, while a burst of 4403 closes means someone is trying to open sockets from an unexpected origin, which deserves a look. Load-test the socket server with realistic editor counts and typing rates before launch; live preview traffic is bursty, and a slow server makes editing feel broken even when authentication is perfect.

How the page reacts to each close codeWhen the socket closes, code 4401 leads to fetching a new ticket or re-entering preview authentication, code 4403 shows an access error, and any other code triggers a reconnect with backoff and a fresh ticket.socket closed4401session or ticket4403origin or scopeother codenetworknew ticket orre-authenticateshow access errorbackoff + reconnectfresh ticket
Distinct close codes turn every disconnect into the right next step instead of a blind retry loop.

Worked Example

After the incident, the publisher rebuilt its channel in two days. The socket URL no longer contained entry ids; the page requested a ticket for its session’s entries and connected with it. The socket server checked the origin, redeemed the ticket with GETDEL, and subscribed only to the ticket’s entries. Sessions closed with code 4401 at expiry, and the page silently fetched a new ticket when the editor’s session was still valid. A penetration test a month later tried the original attack, enumerating entry ids against the socket, and every attempt closed immediately with 4401. Editors noticed no change at all, apart from fewer reconnect glitches, because the new reconnect logic handled expiry deliberately instead of by accident.

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.

  • Inventory every live preview channel, including SDK channels that your code proxies.
  • Add the ticket endpoint and require tickets on every socket connection.
  • Check Origin on handshakes and restrict subscriptions to the ticket’s entries.
  • Close streams at session expiry and on revocation, with distinct close codes.
  • Move SSE endpoints to the preview origin and check the session cookie and scope.

Frequently Asked Questions

Are SDK channels from the CMS vendor affected?

Vendor channels such as a CMS studio’s postMessage bridge run inside the browser between the studio and your preview page, and do not expose drafts on the network beyond what the page already received. The risk addressed here is channels you run yourself, such as a custom socket that relays draft updates.

Can I use the WebSocket subprotocol header for the token?

Some teams pass a token in Sec-WebSocket-Protocol, which avoids query strings. It works, but it abuses a header meant for protocol negotiation and still sends a long-lived credential. A short ticket in the query string is simpler and safer.

How do I test channel authentication?

Write tests that connect without a ticket, with an expired ticket, with a reused ticket, from a wrong origin, and with a ticket for another entry, and assert the close code for each. Then assert that a valid connection receives updates only for its own entry.

Does polling avoid these problems?

Polling requests are ordinary HTTP requests that carry the session cookie, so they inherit the page’s authentication naturally. That is one reason to start with polling and add streaming only when the editing experience needs it.