Authenticating SSE Streams with Tokens & Cookies Permalink to this section

Part of Security Headers for Event Streams.

The EventSource API has a hard constraint that trips up virtually every team that tries to secure a real-time feed: you cannot pass custom HTTP headers. Sending Authorization: Bearer <token> the same way you would in fetch() is not possible. If your SSE endpoint requires a bearer token in the request header, every client opening new EventSource('/stream') will receive a 401 and the browser will silently retry indefinitely, never surfacing the error in the onerror callback with useful detail. This guide documents the four viable authentication patterns, explains when each is appropriate, and gives production-ready code for each approach.

Symptom & Developer Intent Permalink to this section

You have a protected SSE endpoint. On the server you validate Authorization: Bearer <jwt>. You try:

// This does NOT work β€” EventSource has no option to set headers
const es = new EventSource('/stream', {
  headers: { Authorization: `Bearer ${token}` }  // ignored entirely
});

The browser constructs an HTTP/1.1 GET to /stream with no Authorization header. Your server returns 401 Unauthorized. The EventSource readyState cycles between CONNECTING (0) and CLOSED (2); the onerror handler fires but event.target.readyState gives no detail beyond β€œfailed”. Network DevTools shows the 401 but the developer, expecting a working stream, sees only silence.

Related to this: if your endpoint is on a different origin and you don’t pass { withCredentials: true }, cookies are not sent either β€” even when the user is authenticated via HttpOnly session cookies. See Handling CORS in SSE Implementations for the CORS half of that problem.

Root Cause Analysis Permalink to this section

The EventSource interface is defined in the WHATWG HTML specification. Its constructor signature is:

EventSource(url, eventSourceInitDict)

EventSourceInit has exactly one optional key: withCredentials (boolean). There is no headers key, no method key, no body key. The browser’s EventSource implementation issues a plain GET with whatever cookies and CORS credentials the settings dictate β€” nothing more.

This is a deliberate design decision rooted in the HTTP/1.1 era: EventSource was specified to be a simple, browser-managed connection with automatic reconnect. The tradeoff is that every authentication mechanism must work within the constraints of a cookie-credentialled GET request or a URL parameter.

Consequences:

  • Authorization headers are out for native EventSource.
  • Custom headers (X-API-Key, X-CSRF-Token) are also out.
  • Cookies are sent, but only when withCredentials: true is set and the server sets Access-Control-Allow-Credentials: true with a specific Access-Control-Allow-Origin (not *).
  • Query-string tokens work but are logged in server access logs and browser history.
  • The fetch + ReadableStream pattern (used in some polyfills) does support arbitrary headers β€” at the cost of managing reconnect logic yourself.
Mechanism Native EventSource fetch+ReadableStream Security Grade
Authorization header No Yes High
HttpOnly session cookie Yes (with withCredentials) Yes High
Query-string token Yes Yes Medium (logged)
Short-lived ticket (one-time token in URL) Yes Yes High
fetch with bearer + manual reconnect N/A Yes High

Step-by-Step Resolution Permalink to this section

If your app already uses session cookies (HttpOnly; Secure; SameSite=Lax), this is the lowest-friction path.

Client:

// Pass withCredentials so the browser includes cookies on cross-origin requests
const es = new EventSource('https://api.example.com/stream', {
  withCredentials: true
});

es.addEventListener('message', (e) => console.log(JSON.parse(e.data)));
es.addEventListener('error', (e) => {
  if (e.target.readyState === EventSource.CLOSED) {
    console.error('SSE connection closed');
  }
});

Server (Node.js / Express):

import express from 'express';
import cookieParser from 'cookie-parser';
import { verifySession } from './auth.js';

const app = express();
app.use(cookieParser());

app.get('/stream', async (req, res) => {
  const session = await verifySession(req.cookies.session_id);
  if (!session) {
    res.status(401).end();
    return;
  }

  // Required CORS headers for credentialed cross-origin SSE
  res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com'); // never '*' with credentials
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ ts: Date.now(), uid: session.userId })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(interval));
});

SameSite=Strict cookies will not be sent on cross-origin requests even with withCredentials: true. Use SameSite=Lax or SameSite=None; Secure for cross-origin streaming.

Step 2 β€” Query-String Token (Fast Path, Acceptable for Short-Lived JWTs) Permalink to this section

When cookie auth is not available (native mobile webviews, CLI clients, server-to-server), passing a token in the URL is pragmatic β€” provided the token’s lifetime is short.

Client:

// Retrieve a short-lived token (≀60 s TTL) from your auth service
const { token } = await fetch('/auth/sse-token').then(r => r.json());

const es = new EventSource(`/stream?token=${encodeURIComponent(token)}`);

Server (Python / FastAPI):

from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import StreamingResponse
from jose import jwt, JWTError
import asyncio, time, json

app = FastAPI()
SECRET = "your-secret-key"

def verify_sse_token(token: str) -> dict:
    try:
        payload = jwt.decode(token, SECRET, algorithms=["HS256"])
        if payload.get("scope") != "sse":
            raise HTTPException(403, "Wrong token scope")
        return payload
    except JWTError:
        raise HTTPException(401, "Invalid token")

async def event_generator(user_id: str):
    while True:
        yield f"data: {json.dumps({'uid': user_id, 'ts': time.time()})}\n\n"
        await asyncio.sleep(1)

@app.get("/stream")
async def stream(token: str = Query(...)):
    claims = verify_sse_token(token)
    return StreamingResponse(
        event_generator(claims["sub"]),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

Mitigate log exposure: strip the token query parameter in your reverse proxy before writing access logs, or rotate to a ticket system (Step 3).

Step 3 β€” Short-Lived One-Time Ticket (Highest Security) Permalink to this section

Issue a single-use opaque token redeemable only once, within a short window (10–30 s). The ticket is stored server-side (Redis with TTL); after first use it is deleted.

Ticket issuance endpoint (Node.js):

import crypto from 'node:crypto';
import { redis } from './redis.js';

// POST /auth/sse-ticket  (requires valid session or bearer token)
app.post('/auth/sse-ticket', requireAuth, async (req, res) => {
  const ticket = crypto.randomBytes(32).toString('hex');
  // Store userId against ticket; expire after 20 seconds
  await redis.set(`sse:ticket:${ticket}`, req.user.id, 'EX', 20);
  res.json({ ticket });
});

Stream endpoint consuming the ticket:

app.get('/stream', async (req, res) => {
  const { ticket } = req.query;
  if (!ticket) return res.status(401).end();

  // Atomic get-and-delete: one-time use only
  const userId = await redis.getdel(`sse:ticket:${ticket}`);
  if (!userId) return res.status(401).end();  // expired or already used

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.flushHeaders();

  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ uid: userId, ts: Date.now() })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(interval));
});

Client:

async function openAuthenticatedStream() {
  const { ticket } = await fetch('/auth/sse-ticket', { method: 'POST' }).then(r => r.json());
  return new EventSource(`/stream?ticket=${ticket}`);
}

let es = await openAuthenticatedStream();

// On reconnect EventSource re-issues the GET; re-fetch a fresh ticket
es.addEventListener('error', async () => {
  es.close();
  es = await openAuthenticatedStream();
});

Because the ticket is consumed on first use, a replayed URL is harmless after 20 seconds. This pattern pairs well with Idempotent Event ID Generation β€” the reconnect flow re-authenticates and sends Last-Event-ID simultaneously.

Step 4 β€” fetch + ReadableStream with Bearer Token (Full Control) Permalink to this section

Drop native EventSource in favour of fetch when you need arbitrary headers. You must implement reconnect and Last-Event-ID tracking yourself. The Event ID & Retry Mechanism Design guide covers the retry wire format.

async function openStream(token, lastEventId = null) {
  const headers = {
    'Accept': 'text/event-stream',
    'Authorization': `Bearer ${token}`,
    'Cache-Control': 'no-cache',
  };
  if (lastEventId) headers['Last-Event-ID'] = lastEventId;

  const response = await fetch('/stream', { headers, signal: AbortSignal.timeout(0) });
  if (!response.ok) throw new Error(`SSE auth failed: ${response.status}`);

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let currentId = lastEventId;

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const events = buffer.split('\n\n');
    buffer = events.pop(); // keep incomplete event

    for (const block of events) {
      const lines = block.split('\n');
      let data = '', id = '';
      for (const line of lines) {
        if (line.startsWith('data:')) data += line.slice(5).trim();
        if (line.startsWith('id:')) id = line.slice(3).trim();
      }
      if (id) currentId = id;
      if (data) dispatchEvent(new CustomEvent('sse', { detail: { data, id: currentId } }));
    }
  }
  return currentId; // return last seen id for reconnect
}

// Reconnect loop with exponential back-off
async function connectWithRetry(token) {
  let lastId = null;
  let delay = 1000;
  while (true) {
    try {
      lastId = await openStream(token, lastId);
      delay = 1000; // reset on clean close
    } catch (err) {
      console.warn('SSE disconnected, retrying in', delay, 'ms', err);
      await new Promise(r => setTimeout(r, delay));
      delay = Math.min(delay * 2, 30000);
    }
  }
}

Pair this with a token-refresh routine: if openStream receives a 401, refresh the access token via your OAuth flow and retry before incrementing the back-off delay.

Validation & Monitoring Permalink to this section

# Obtain a session cookie, then test the SSE endpoint
curl -c /tmp/cookies.txt -b /tmp/cookies.txt \
  -H "Accept: text/event-stream" \
  -v https://api.example.com/stream 2>&1 | head -40

# Expected: HTTP/2 200, content-type: text/event-stream, stream of data: lines
# Failure: HTTP/2 401 β€” cookie not sent or session invalid

Verify Query-String Token with curl Permalink to this section

TOKEN=$(curl -s -X POST https://api.example.com/auth/sse-token \
  -H "Authorization: Bearer $LONG_LIVED_JWT" | jq -r .token)

curl -N -H "Accept: text/event-stream" \
  "https://api.example.com/stream?token=$TOKEN"

DevTools Verification Steps Permalink to this section

  1. Open Network tab β†’ filter by EventSource (or Fetch/XHR for the fetch pattern).
  2. Click the /stream request β†’ Headers β†’ confirm Cookie is present (for cookie auth) or Authorization appears (fetch pattern).
  3. Click EventStream sub-tab β†’ confirm event rows appear.
  4. Simulate token expiry: invalidate the session server-side β†’ confirm 401 triggers reconnect logic and a fresh ticket/token is fetched (not an infinite retry loop).

Unit-Test Stub (Node.js / Vitest) Permalink to this section

import { describe, it, expect, vi } from 'vitest';
import request from 'supertest';
import { app } from '../src/app.js';

describe('SSE auth', () => {
  it('rejects requests without a valid ticket', async () => {
    const res = await request(app).get('/stream?ticket=bogus')
      .set('Accept', 'text/event-stream');
    expect(res.status).toBe(401);
  });

  it('accepts a freshly issued ticket', async () => {
    // Issue ticket with a mock authenticated session
    const ticketRes = await request(app)
      .post('/auth/sse-ticket')
      .set('Cookie', 'session_id=valid-session');
    expect(ticketRes.status).toBe(200);

    const { ticket } = ticketRes.body;
    const streamRes = await request(app)
      .get(`/stream?ticket=${ticket}`)
      .set('Accept', 'text/event-stream')
      .buffer(false);
    expect(streamRes.status).toBe(200);
    expect(streamRes.headers['content-type']).toMatch(/text\/event-stream/);
  });
});

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I set the Authorization header on a native EventSource?

No. The WHATWG HTML spec defines EventSourceInit with only one option: withCredentials. There is no headers key. Any library that claims otherwise is either wrapping fetch internally or using a browser extension. If you need arbitrary headers, use fetch with a ReadableStream and implement reconnect yourself as shown in Step 4.

What happens when an SSE bearer token expires mid-stream?

For the fetch+ReadableStream pattern: the server can close the response with a terminal event (e.g., event: auth_expired\ndata: {}\n\n) and a 401 status on the next keep-alive check. The client detects the closure, refreshes the token, and reopens the stream. For native EventSource with query-string tokens: the stream will simply keep alive as long as the server holds the connection open; expiry is only enforced on the next reconnect attempt. To force re-authentication, close the server-side connection after a maximum duration (e.g., 1 hour) regardless of activity.

Are query-string tokens safe to use in production?

They are acceptable under two conditions: (1) the token has a short TTL (≀ 60 s) and is scoped to SSE-only, and (2) access logs are configured to redact or omit the token/ticket query parameter. Tokens in URLs are also visible in browser history and Referer headers. For higher-sensitivity data, prefer the one-time ticket pattern (Step 3) or cookie auth (Step 1).

Do HttpOnly cookies sent via withCredentials prevent CSRF attacks on SSE?

SSE endpoints accept only GET requests. GETs are generally considered safe operations (idempotent, no side effects), so CSRF risk is lower than for state-mutating endpoints. However, if your SSE endpoint's URL implicitly encodes destructive actions, add a SameSite=Strict cookie or a secondary CSRF token in the query string. For read-only streams, SameSite=Lax is sufficient.

Can I use OAuth PKCE and refresh tokens with the fetch SSE pattern?

Yes. Store the access token in memory (not localStorage). Before calling openStream(), check token expiry and call the OAuth token endpoint if needed. In the reconnect loop, catch 401 specifically, run the refresh flow, then retry the stream with the new access token. Never embed refresh tokens in query strings; only the short-lived access token belongs in the stream request.

⚑ Production Directives

  • Never use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true β€” browsers will block the request and your stream will silently fail.
  • Limit query-string token TTL to ≀ 60 seconds and scope the JWT claim to SSE only ("scope": "sse") to reduce blast radius if a URL leaks.
  • Use Redis GETDEL (atomic) for one-time tickets β€” two-step GET + DEL has a race condition that allows double-use.
  • Force-close long-lived SSE connections server-side after a configurable maximum duration (e.g., 3600 s) to trigger re-authentication; do not rely on token TTL alone for cookie-authenticated streams.
  • Strip token and ticket query parameters from access logs using your reverse proxy's log format before logs are written to disk or shipped to a SIEM.