Adding Auth Headers to SSE Requests Permalink to this section

Part of Fetch-Based SSE Clients, under Frontend Consumption & Client Patterns.

new EventSource(url) has no way to set request headers. For applications that authenticate API calls with an Authorization: Bearer … header — single-page apps using OAuth or OpenID Connect, mobile web views, anything behind an API gateway that expects a token — the stream cannot authenticate the same way as everything else. This guide covers the three workable approaches, with the token-refresh and error handling each needs, and when to choose which.

Symptom & Developer Intent Permalink to this section

  • The stream endpoint returns 401 while every other API call succeeds.
  • A workaround puts the access token in the query string, and security review flags tokens in access logs.
  • The stream works for an hour, then fails permanently when the access token expires.
  • After a token refresh, the stream keeps reconnecting with the old, expired token.
  • A polyfill that adds headers to EventSource behaves differently from the native API.

The intent is an authenticated stream that uses the same identity as the rest of the application, survives token rotation, and never places long-lived credentials in URLs.

Root Cause Analysis Permalink to this section

The EventSource constructor takes a URL and an options object with a single option, withCredentials, which controls whether cookies are sent cross-origin. There is no header option. Three ways around it exist, each moving the credential somewhere else:

Three ways to authenticate an SSE stream Matrix comparing a fetch-based client with an Authorization header, cookie authentication with EventSource, and a short-lived stream ticket in the query string. Three ways to authenticate an SSE stream Approach Keeps EventSource Token in URL Refresh handling CSRF concerns fetch + Authorization no, own client never per reconnect none Cookie session yes never by browser SameSite rules Short-lived ticket yes single-use, 30 s new ticket each time none good acceptable problem
Use a header when the app is token-based, cookies when it already has a session, and tickets only when neither fits.

Tokens in URLs are a problem because URLs are logged by servers, proxies and CDNs, stored in browser history and sent in Referer headers. A long-lived access token there can be replayed by anyone who reads the logs.

The expiry symptom has a subtler cause: EventSource reconnects with the same URL forever, and a fetch client that captured the token once at start-up does the same. The token must be read fresh for every connection attempt.

Step-by-Step Resolution Permalink to this section

Step 1 — Use a fetch-based client with a header provider Permalink to this section

import { fetchEventSource } from './fetch-sse.js';   // the client from the fetch-based clients topic

const stop = fetchEventSource('/api/stream', {
  headers: async () => ({ Authorization: `Bearer ${await auth.getAccessToken()}` }),   // fresh per attempt
  onEvent: handle,
  onError: (err) => {
    if (err.status === 401) auth.invalidate();        // force a refresh before the next attempt
  },
});

auth.getAccessToken() returns a cached token if it is still valid and refreshes it otherwise; the OIDC or OAuth library in use usually provides exactly this. Because headers are resolved per connection attempt, a reconnect after expiry automatically carries a new token.

Recovering from an expired token on reconnect Sequence diagram of a fetch-based stream reconnecting with an expired token, receiving 401, invalidating the cached token, refreshing it with the identity provider, and reconnecting successfully. Recovering from an expired token on reconnect Stream client Auth layer Identity provider API GET /stream Bearer (expired) 401 invalidate, getAccessToken() refresh_token grant new access token GET /stream Bearer (new), Last-Event-ID 200, replay, live
The 401 is handled once, by the auth layer, and the stream resumes from its last event id with a valid token.

Make the refresh single-flight: if several streams and API calls hit 401 at once, only one refresh request should go to the identity provider, with the others awaiting its result. Most auth libraries do this; if yours does not, wrap getAccessToken so concurrent callers share one pending promise.

Step 2 — End streams at token expiry on the server Permalink to this section

A token that was valid at connect may expire while the stream is open. The server should not keep streaming to an expired identity indefinitely: end the stream shortly after the token’s exp, with a short retry hint, so the client reconnects with a fresh token.

const msLeft = claims.exp * 1000 - Date.now();
const expire = setTimeout(() => { res.write('retry: 500\nevent: reauth\ndata: {}\n\n'); res.end(); }, Math.max(0, msLeft));
req.on('close', () => clearTimeout(expire));
A stream across two token lifetimes Timeline of two hours showing a stream authenticated with a first token, ended by the server at expiry, and immediately reconnected with a refreshed token. A stream across two token lifetimes Access token Stream token 1 token 2 connection 1 connection 2 0 24 48 72 96 120 minutes exp → reauth
The stream is never older than its credential. The handover at expiry costs one reconnect and replays nothing the client has not seen.

Step 3 — Or keep EventSource and use cookies Permalink to this section

If the application already has a cookie session (server-rendered apps, or SPAs using a backend-for-frontend), authenticate the stream with the same cookie:

const es = new EventSource('https://api.example.com/stream', { withCredentials: true });

Cross-origin, the server must reply with Access-Control-Allow-Origin set to the exact origin and Access-Control-Allow-Credentials: true, and the cookie must be SameSite=None; Secure if the origins are different sites. Same-site setups can keep SameSite=Lax. Since the stream is a GET that changes nothing, CSRF is not a concern for the stream itself. See handling CORS in SSE implementations.

Step 4 — Or exchange a bearer token for a short-lived stream ticket Permalink to this section

When EventSource must be kept and cookies are not available, exchange the access token for a ticket that is single-use and valid for seconds:

// Client
async function openStream() {
  const { ticket } = await (await fetch('/api/stream-tickets', {
    method: 'POST', headers: { Authorization: `Bearer ${await auth.getAccessToken()}` },
  })).json();
  const es = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`);
  es.onerror = () => { if (es.readyState === EventSource.CLOSED) setTimeout(openStream, backoff.next()); };
  return es;
}

// Server: tickets expire in 30 s and are deleted on first use.
app.post('/api/stream-tickets', requireBearer, async (req, res) => {
  const ticket = crypto.randomBytes(24).toString('base64url');
  await redis.set(`ticket:${ticket}`, req.user.id, 'EX', 30);
  res.json({ ticket });
});

A leaked ticket is worthless seconds later. The catch: EventSource’s automatic reconnection reuses the URL, and the ticket has already been used. The stream endpoint should therefore answer a reused ticket with 401 — which fails the connection permanently — and the client’s onerror handler obtains a new ticket and reopens, as shown. The stream is still resumable if the client keeps the last event id and passes it as a query parameter on reopen.

Validation & Monitoring Permalink to this section

# Header auth: 401 without a token, 200 with one.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/stream
curl -sN -H "Authorization: Bearer $TOKEN" https://api.example.com/stream | head -2

# Tickets are single-use.
T=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" https://api.example.com/api/stream-tickets | jq -r .ticket)
curl -s -o /dev/null -w '%{http_code}\n' --max-time 2 "https://api.example.com/api/stream?ticket=$T"   # 200
curl -s -o /dev/null -w '%{http_code}\n' --max-time 2 "https://api.example.com/api/stream?ticket=$T"   # 401

Scan access logs for bearer tokens in URLs; there should be none. Track 401s on the stream endpoint separately: a spike aligned with token lifetimes means clients are reconnecting with stale tokens.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I add headers to EventSource with a polyfill?

Some polyfills accept a headers option by reimplementing EventSource on top of fetch or XHR. At that point you are using a fetch-based client with an EventSource-shaped API, which is fine, but it no longer is the native implementation.

Is putting the access token in the query string acceptable over HTTPS?

HTTPS protects the URL in transit, but not in server logs, proxy logs, browser history or analytics. Use a header, a cookie or a single-use ticket instead.

Do I need CSRF protection on the stream endpoint?

A GET stream that only reads data does not change state, so classic CSRF does not apply. Make sure it cannot be read cross-origin by misconfigured CORS, which is the real risk.

How often should streams re-authenticate?

At least when the credential expires. For sensitive data, also end streams when a user's permissions change, rather than waiting for the token to expire.