Server-Sent Events look like the easiest real-time transport to secure. The request is a plain HTTP GET, the response is a plain HTTP body, and every proxy, load balancer and WAF in your stack already understands both. Then you try to send your API's bearer token and find there is nowhere to put it. The browser's EventSource API takes a URL and one boolean, withCredentials. It has no option for request headers, so the Authorization: Bearer header that every other call in your app uses cannot be sent.
That gap pushes teams toward tokens in query strings, cookie sessions with CORS subtleties, or a hand-rolled fetch reader. Meanwhile the stream outlives its credential and the browser reconnects on its own with the same URL. This article works through each option, shows code for the designs that hold up, and covers expiry, revocation and production failure modes.
Four constraints that shape every design
Four properties of SSE, all from the WHATWG HTML specification, shape every authentication design:
- No custom headers. The constructor is
new EventSource(url, { withCredentials }). The user agent setsAccept: text/event-streamand, on reconnect,Last-Event-ID. Anything else must ride in the URL or in cookies. - Automatic reconnection with the same URL. When the response body ends normally or the network drops, the browser waits the
retryinterval and requests the same URL again. Your code is not consulted, so anything in the URL that expires or is single-use will break. - Non-200 responses are fatal. If the status is not 200 or the
Content-Typeis nottext/event-stream, the browser fails the connection.readyStatebecomesCLOSED, oneerrorevent fires, and no retry follows. A 401 stops the stream permanently. - The error event carries no status.
onerrorcannot tell a 401 from a 503; it can only see whetherreadyStateisCONNECTING(retrying) orCLOSED(given up).
Add the usual long-lived-connection problems: authorization is checked at connect but the stream runs for hours, and a mass reconnect after a deploy hits your auth service as a burst.
Where the credential can travel
| Credential carrier | Works with EventSource | Main risk | Use when |
|---|---|---|---|
| Session cookie | Yes; cross-origin needs withCredentials and CORS | CSRF-style misuse, cookie scope too broad | Your app already uses cookie sessions on the same site |
| Long-lived token in query string | Yes | Token copied into access, CDN and APM logs and browser history | Never in production |
| Short single-use ticket in query string | Yes, if reconnects are handled | Ticket replay inside its TTL; reconnect breaks unless converted | Token-based SPA or mobile web that must keep native EventSource |
Authorization header via fetch | No; you replace EventSource | You now own reconnect, resume and visibility handling | You already need POST bodies or custom headers, e.g. LLM streaming |
The long-lived token in the URL is the most common design in real code and the one to remove first. Reverse proxies, CDNs, load balancers, APM agents and web frameworks all log the full query string by default, so anyone who can read logs can replay the token until it expires. The secure rows are not free either: they move complexity into a ticket service or a client library you now maintain.
Cookie sessions, CORS and cross-site requests
If your web app already authenticates with an HttpOnly session cookie, SSE is easiest. A same-origin new EventSource('/events') sends the cookie automatically, the server validates it like any other request, and reconnects send it again. For a stream served from a different origin, such as app.example.com calling stream.example.com, pass { withCredentials: true }. That sets the fetch credentials mode to include, and the response must then carry Access-Control-Allow-Origin with the exact requesting origin (not *) plus Access-Control-Allow-Credentials: true. Without both, the browser discards the response and the client sees a fatal error.
Cookie scope matters more than usual here. SameSite is about sites (registrable domains), not origins, so app.example.com and stream.example.com are same-site and Lax cookies flow. A stream on a completely different domain needs SameSite=None; Secure, and third-party cookie restrictions in modern browsers may block it outright. The practical rule: serve the stream from the same registrable domain as the app.
Any page on the internet can point an EventSource at your endpoint, and if the cookie is sent your server starts pushing that user's events. CORS stops the attacker's page from reading them, but only if you never answer with a reflected origin; allowlist exact origins and send Vary: Origin. Each cross-site connection also holds a server slot, so reject unknown Origin values with a 403 before allocating anything.
Ticket exchange that survives reconnects
If your SPA holds an access token in memory and has no cookie session, the standard fix is a ticket exchange. Before opening the stream, the client calls an ordinary authenticated endpoint with its bearer header. The server returns a random, single-use ticket that lives about 30 seconds and is bound to the user, and the client puts that ticket in the stream URL. The secret that reaches the logs is spent within seconds, so a log reader cannot replay it.
The naive version breaks at the first network blip: EventSource reconnects with the same URL, the consumed ticket gets a 401, and the stream dies for good. One fix is client-side: on readyState === CLOSED, fetch a new ticket and build a new EventSource, passing the last seen ID as a query parameter because a new object cannot send Last-Event-ID. The better fix is to redeem the ticket into an HttpOnly; Secure cookie scoped to Path=/events, so browser reconnects carry the cookie and the server checks it before looking at the URL. Cross-origin streams need withCredentials for that cookie to be stored and sent.
// Ticket issue: an ordinary bearer-authenticated endpoint
app.post('/sse-ticket', requireBearer, async (req, res) => {
const ticket = crypto.randomBytes(32).toString('base64url');
await redis.set(`sset:${ticket}`, JSON.stringify({
sub: req.user.id, origin: req.get('Origin'), exp: req.token.exp,
}), { EX: 30 });
res.json({ ticket });
});
// Redemption: atomic get-and-delete, then convert to a path-scoped cookie
app.get('/events', async (req, res) => {
// Cookie first: browser reconnects reuse the URL, so the spent ticket is still in it
let session = await streamSessions.get(req.cookies.sse);
if (!session && req.query.ticket) {
const raw = await redis.getDel(`sset:${req.query.ticket}`); // single use
if (!raw) return res.status(401).end();
const t = JSON.parse(raw);
if (t.origin !== req.get('Origin')) return res.status(403).end();
session = await streamSessions.create(t.sub, t.exp);
res.cookie('sse', session.id, { httpOnly: true, secure: true,
sameSite: 'strict', path: '/events', expires: new Date(t.exp * 1000) });
}
if (!session) return res.status(401).end();
// write the text/event-stream headers, then attachStream (next section)
});Redeem atomically with Redis GETDEL (or a Lua script on older servers); a separate GET and DEL lets two requests redeem one ticket. Keep the ticket opaque, with claims stored server-side, and bind it to the origin. Redact ticket= in log pipelines anyway, for the day someone raises the TTL to an hour.
Fetch streaming with an Authorization header
The alternative is to drop EventSource and read the stream with fetch, which can send any header, method and body. That is why most LLM chat front ends stream this way: the prompt is a POST anyway. The cost is that parsing, reconnecting with backoff, sending Last-Event-ID and hidden-tab handling become your code; the reconnection and Last-Event-ID guide covers that contract.
Microsoft's @microsoft/fetch-event-source implements the parser and retry loop. It takes headers, signal, onopen, onmessage, onclose and onerror; throwing from onerror stops retries and returning a number sets the delay. By default it closes the request while the page is hidden and resumes with the last event ID when it is visible again. Releases have been quiet for a long time, so read its open issues before depending on it; the parser is small enough to vendor.
import { fetchEventSource } from '@microsoft/fetch-event-source';
class Fatal extends Error {}
class Reauth extends Error {}
class Retriable extends Error {}
async function stream(onEvent) {
for (;;) { // one pass per access token
const ctrl = new AbortController();
try {
await fetchEventSource('/events', {
headers: { Authorization: `Bearer ${await tokens.current()}` },
signal: ctrl.signal,
async onopen(res) {
if (res.ok && res.headers.get('content-type')?.startsWith('text/event-stream')) return;
if (res.status === 401) throw new Reauth();
if (res.status >= 500 || res.status === 429) throw new Retriable(); // backoff retry
throw new Fatal(String(res.status));
},
onmessage(ev) {
if (ev.event === 'reauth') { ctrl.abort(); return; } // server asked us to rotate
onEvent(ev);
},
onerror(err) { if (err instanceof Fatal || err instanceof Reauth) throw err; }, // else retry
});
} catch (err) {
if (err instanceof Fatal) throw err; // 403, 404: give up and surface it
}
await tokens.refresh(); // abort and normal close resolve; Reauth rejects; all rotate here
}
}Throw on a 5xx rather than returning from onopen, which the library treats as success; the call would resolve and the outer loop would reconnect with no backoff. The outer loop exists because headers are fixed per call and reused for internal retries, so a refreshed token never reaches them. An Authorization header on a cross-origin request also triggers a CORS preflight; set Access-Control-Max-Age so reconnect storms do not double your request count.
Expiry, rotation and revocation on open streams
A stream authorized at 09:00 with a 15-minute token is still streaming at 17:00 unless the server enforces the credential's lifetime.
- Close at expiry. Set a timer for expiry minus a grace period; when it fires, send
event: reauthand end the response. EventSource reconnects on its own, and if the app's normal refresh has renewed the session or path cookie, the reconnect authenticates with the new credential. If not, it gets 401 and the app routes to login. - Spread the closes. A 09:00 login wave with one-hour tokens becomes a 10:00 reconnect wave; add 0 to 10 percent jitter.
- Fan out revocation. Logout, password change and suspension must close streams within seconds. Publish revocations on a channel every stream node consumes. Closing the socket is not enough, because EventSource reconnects with the same cookie; invalidate the stream session first so the reconnect gets a 401.
- Authorize every event. Check current membership, cached for seconds, when routing each event rather than trusting the topic list computed at connect. The bidi security article goes deeper on per-message authorization.
function attachStream(req, res, session) {
const jitter = Math.random() * 0.1 * (session.expiresAt - Date.now());
const closeIn = Math.max(1000, session.expiresAt - Date.now() - 30_000 - jitter);
const end = (why) => { res.write(`event: reauth\ndata: {"reason":"${why}"}\n\n`); res.end(); };
const expiry = setTimeout(() => end('expiry'), closeIn);
const offRevoke = revocations.onSession(session.id, async () => {
await streamSessions.delete(session.id); // else the auto-reconnect gets back in
end('revoked');
});
const unsub = bus.subscribe(session.userId, async (ev) => {
if (!(await authz.canSee(session.userId, ev.resource))) return; // per event
res.write(`id: ${ev.id}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
});
req.on('close', () => { clearTimeout(expiry); offRevoke(); unsub(); });
}
Worked example: a support dashboard
Consider a support dashboard where agents keep a stream open all shift for new-ticket and assignment events. The SPA holds a 15-minute OIDC access token and opens /events?access_token=.... Two problems follow. Every live token sits in 30 days of CDN logs. And the first reconnect after expiry sends the expired token, gets 401, and the dashboard silently stops updating until someone presses F5.
The fix is three changes. Add a /sse-ticket endpoint with a 30-second TTL that redeems into a Path=/events cookie tracking the token's lifetime. Have the SPA's existing refresh handler also call a small POST /events/cookie to re-issue that cookie. Close each stream at expiry minus 30 seconds minus jitter, so the browser reconnects with the fresh cookie and replays missed events through Last-Event-ID. Add redaction rules for ticket= and access_token=, and track client-side CLOSED transitions to prove the fix.
Failure modes
- Silent death after the first expiry. The stream returns 401 on reconnect and EventSource gives up. Count client-side transitions to
CLOSEDand report them; a server cannot see a reconnect that never happened. - Tokens in logs. Grep a day of edge and app logs for
token=,jwtandeyJ, the base64 prefix of every JWT header. - Reflected CORS origin with credentials. Any site can read the stream. Allowlist exact origins and add
Vary: Originso caches do not serve one origin's headers to another. - Ticket double redemption. A non-atomic get-then-delete lets two tabs or an attacker race the same ticket.
- Reconnect storms. A deploy drops every stream at once; cache session lookups briefly and drain nodes gradually.
- Revoked users still streaming. Without fan-out, or when only the socket is closed, a fired employee keeps watching.
Trade-offs
Cookies are simplest and keep native reconnect but tie you to browser cookie policy and careful CORS. Tickets keep native EventSource for token apps at the cost of a small stateful service. Fetch with a header keeps secrets out of URLs entirely, but you own reconnection and resume. The SSE vs WebSocket decision guide compares transports, and since browser WebSockets cannot set headers either, the WebSocket authentication patterns carry over.
What to do next
- Search one day of proxy, CDN and application logs for tokens in query strings; if any appear, rotate those credentials and plan the migration.
- Pick one carrier per client type: cookie for same-site web apps, ticket plus path cookie for token SPAs that keep EventSource, fetch with a header for clients that need POST bodies.
- Make the ticket redemption atomic, origin-bound, single-use and 30 seconds or less.
- Close every stream at credential expiry minus a grace period plus jitter, and send a
reauthevent first. - Wire logout, password change and role removal to a revocation channel that every stream node consumes.
- Authorize each event against current permissions, cached for seconds.
- Count client-side
CLOSEDtransitions and 401s on/events; alert when either rises after a deploy or an identity provider change. - Test the full 401 and expiry path through the production edge with a deliberately short-lived token.