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.
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:
- 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.
- 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.
- 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.
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.
// 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
Originheader, 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-storeandX-Accel-Buffering: nowhere 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.
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
Originon 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.