Handling CORS in SSE Implementations Permalink to this section

Part of Security Headers for Event Streams.

When an EventSource connection crosses origins, the browser enforces CORS before a single byte of stream data reaches your application. A wrong or missing header kills the connection instantly — no graceful error, no retry countdown, just a blocked handshake. This guide shows exactly which headers to set on the server, how credential mode changes the rules, where preflight fits in, and how to confirm the fix in production.

Symptom & Developer Intent Permalink to this section

The canonical console error is:

Access to fetch at 'https://api.example.com/stream' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Alongside this, the Network tab shows the GET /stream request with status (failed) or CORS error, zero bytes transferred, and the EventSource object’s readyState cycling from 0 (CONNECTING) directly to 2 (CLOSED) — triggering an onerror callback with no useful message.

The developer intent is straightforward: allow a browser app on https://app.example.com to open a long-lived text/event-stream connection to an API on https://api.example.com without weakening the same-origin policy. The challenge is that SSE connections persist for minutes or hours, so headers must be correct on the initial response — there is no second chance to patch headers mid-stream.

Root Cause Analysis Permalink to this section

How EventSource sits inside the CORS model Permalink to this section

EventSource is a browser-managed HTTP GET — not a custom fetch. The browser attaches an Origin request header and then inspects the response for Access-Control-Allow-Origin before handing any body bytes to your JavaScript. Because native EventSource cannot send custom request headers, the request always qualifies as a CORS simple requestno preflight OPTIONS is issued.

The browser blocks the response if any of these conditions fail:

Condition Credential-free (withCredentials: false) With credentials (withCredentials: true)
Access-Control-Allow-Origin value * or exact origin Exact origin only — * is rejected
Access-Control-Allow-Credentials Not required Must be true
Cookie / Authorization sent No Yes (if withCredentials: true)

The wildcard * cannot be combined with Access-Control-Allow-Credentials: true. Browsers reject that combination unconditionally per the Fetch spec.

Why proxies and CDNs make this worse Permalink to this section

A reverse proxy sitting in front of your SSE endpoint may strip the Origin request header before it reaches your application server, making origin validation impossible. Or the proxy adds its own Access-Control-Allow-Origin: * on cached responses — which then conflicts with your credential requirement. SSE streams also require proxy_buffering off in Nginx and similar directives; missing that directive causes the proxy to buffer the entire response, which both delays events and can prevent CORS headers from being flushed promptly to the browser.

Understanding Buffer Management & Chunked Transfer Encoding is relevant here: proxy buffering interacts with header delivery timing.

Step-by-Step Resolution Permalink to this section

Step 1 — Serve the correct CORS headers on the SSE endpoint Permalink to this section

On every SSE response, set Access-Control-Allow-Origin to the exact requesting origin (dynamic reflection against an allowlist) and, when credentials are involved, Access-Control-Allow-Credentials: true.

Node.js / Express:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.get('/stream', (req, res) => {
  const origin = req.headers['origin'];

  // Reject unknown origins before writing any headers
  if (!origin || !ALLOWED_ORIGINS.has(origin)) {
    res.status(403).end();
    return;
  }

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform', // no-transform stops some proxies recompressing
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',               // tell Nginx not to buffer this response
    'Access-Control-Allow-Origin': origin,    // exact origin, never '*' with credentials
    'Access-Control-Allow-Credentials': 'true',
  });

  // Flush headers immediately; some Node versions buffer until first res.write()
  res.write(': ping\n\n'); // SSE comment — keeps the connection alive and flushes headers

  const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);

  req.on('close', () => {
    clearInterval(heartbeat);
    res.end();
  });
});

Python / FastAPI with sse-starlette:

from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse
import asyncio

app = FastAPI()

ALLOWED_ORIGINS = {"https://app.example.com", "https://admin.example.com"}

@app.get("/stream")
async def stream(request: Request):
    origin = request.headers.get("origin", "")
    if origin not in ALLOWED_ORIGINS:
        from fastapi.responses import Response
        return Response(status_code=403)

    async def event_generator():
        while True:
            if await request.is_disconnected():
                break
            yield {"data": "heartbeat"}
            await asyncio.sleep(25)

    headers = {
        "Access-Control-Allow-Origin": origin,
        "Access-Control-Allow-Credentials": "true",
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",
    }
    return EventSourceResponse(event_generator(), headers=headers)

For a full FastAPI streaming setup, see Python FastAPI SSE Implementation Guide.

Step 2 — Handle OPTIONS preflight for fetch-based SSE clients Permalink to this section

Native EventSource never triggers a preflight. But if you replace EventSource with a fetch() call to gain custom headers (for token auth in the Authorization header, for example), the browser will send a preflight OPTIONS request. Your server must respond to it before the actual GET stream can proceed.

Express preflight handler:

// Place before the GET /stream route
app.options('/stream', (req, res) => {
  const origin = req.headers['origin'];
  if (!origin || !ALLOWED_ORIGINS.has(origin)) {
    return res.status(403).end();
  }

  res.set({
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Allow-Methods': 'GET, OPTIONS',
    // Last-Event-ID is sent as a request header on reconnect
    'Access-Control-Allow-Headers': 'Authorization, Last-Event-ID, Cache-Control',
    'Access-Control-Max-Age': '86400', // cache preflight for 24 h
  }).status(204).end();
});

Nginx reverse proxy with preflight and streaming:

location /stream {
    # Handle preflight without hitting the upstream
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin   $http_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Allow-Methods  "GET, OPTIONS" always;
        add_header Access-Control-Allow-Headers  "Authorization, Last-Event-ID, Cache-Control" always;
        add_header Access-Control-Max-Age        86400 always;
        return 204;
    }

    proxy_pass http://backend_sse;
    proxy_http_version 1.1;
    proxy_set_header  Connection  "";        # required for keep-alive to upstream
    proxy_set_header  Host        $host;
    proxy_set_header  Origin      $http_origin; # preserve Origin for the app server
    proxy_buffering   off;                   # critical — do not buffer SSE body
    proxy_cache       off;
    proxy_read_timeout 3600s;               # keep the proxy connection alive for 1 h

    add_header Access-Control-Allow-Origin      $http_origin always;
    add_header Access-Control-Allow-Credentials true always;
}

The always flag on add_header ensures headers are present on non-2xx responses (including 401 or 403 that your app may return).

Step 3 — Initialize the client with the correct credential mode Permalink to this section

Match the client’s credential mode to the server’s Access-Control-Allow-Credentials header.

Native EventSource (cookie / session auth):

const source = new EventSource('https://api.example.com/stream', {
  withCredentials: true, // sends cookies; server must NOT return ACAO: *
});

source.addEventListener('open', () => {
  console.log('SSE connection open, readyState:', source.readyState); // 1
});

source.addEventListener('message', (e) => {
  console.log('Event data:', e.data);
});

source.addEventListener('error', (e) => {
  // readyState 0 = CONNECTING (browser will retry), 2 = CLOSED (permanent failure)
  if (source.readyState === EventSource.CLOSED) {
    console.error('SSE closed permanently — check CORS headers');
  }
});

fetch + ReadableStream (for custom Authorization header):

const controller = new AbortController();

async function connectSSE(token) {
  const res = await fetch('https://api.example.com/stream', {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Last-Event-ID': sessionStorage.getItem('lastEventId') ?? '',
    },
    credentials: 'include', // equivalent to withCredentials: true
    signal: controller.signal,
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.getReader();
  const dec = new TextDecoder();
  let buf = '';

  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    const blocks = buf.split('\n\n');
    buf = blocks.pop() ?? '';
    for (const block of blocks) {
      const data = block.split('\n')
        .filter(l => l.startsWith('data:'))
        .map(l => l.slice(5).trim())
        .join('\n');
      if (data) console.log('Received:', data);
    }
  }
}

connectSSE(myToken).catch(err => console.error('SSE error:', err));
// To disconnect: controller.abort();

Note that fetch-based SSE does not auto-reconnect. You must implement exponential backoff manually. See Error Handling & Reconnection UX for a production reconnect strategy.

Step 4 — Avoid wildcard origins with credentials Permalink to this section

This combination is explicitly forbidden by the Fetch specification and all browsers reject it:

# INVALID — browser rejects this combination
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

If you need to allow credentials, you must reflect the exact Origin from the request. Store allowed origins in a Set (or environment variable) and validate on every request:

function setCorsHeaders(req, res) {
  const origin = req.headers['origin'];
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    // Vary tells downstream caches the response differs by Origin
    res.setHeader('Vary', 'Origin');
  }
}

Always set Vary: Origin when reflecting the origin dynamically. Without it, a shared cache (CDN or reverse proxy) may serve one client’s CORS-permitted response to a request from a different origin, causing incorrect allow/deny behavior.

For authentication patterns beyond CORS, see Authenticating SSE Streams with Tokens & Cookies.

Validation & Monitoring Permalink to this section

curl verification Permalink to this section

# Simulate the browser's initial CORS request (no preflight for simple GET)
curl -v \
  -H "Origin: https://app.example.com" \
  -H "Accept: text/event-stream" \
  https://api.example.com/stream 2>&1 | grep -E "< (Access-Control|Content-Type|Vary)"

# Expected output:
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Credentials: true
# < Content-Type: text/event-stream
# < Vary: Origin
# Verify preflight if using fetch with custom headers
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization, Last-Event-ID" \
  https://api.example.com/stream 2>&1 | grep -E "< (HTTP|Access-Control)"

# Expected: HTTP/1.1 204, then ACAO, ACAC, ACAM, ACAH headers

DevTools checklist Permalink to this section

Open the Network tab, filter by EventStream, select the SSE request, and verify:

  1. Response Headers tab shows Access-Control-Allow-Origin with your app’s exact origin.
  2. Request Headers tab shows Origin: https://app.example.com was sent.
  3. EventStream sub-tab populates with events (not empty).
  4. No red CORS error entry in the Console.

Integration test stub (Node.js + undici) Permalink to this section

import { fetch } from 'undici';
import assert from 'node:assert/strict';

const res = await fetch('http://localhost:3000/stream', {
  headers: {
    Origin: 'https://app.example.com',
    Accept: 'text/event-stream',
  },
});

assert.equal(
  res.headers.get('access-control-allow-origin'),
  'https://app.example.com',
  'ACAO must reflect exact origin'
);
assert.equal(
  res.headers.get('access-control-allow-credentials'),
  'true',
  'ACAC must be true'
);
assert.equal(
  res.headers.get('content-type'),
  'text/event-stream; charset=utf-8',
  'Content-Type must be text/event-stream'
);
assert.ok(
  res.headers.get('vary')?.includes('Origin'),
  'Vary must include Origin'
);

res.body.cancel(); // don't hang the test
console.log('CORS headers: OK');

Production monitoring signals Permalink to this section

  • Track the rate of EventSource onerror callbacks that are followed by immediate CLOSED state (no reconnect). A spike indicates a CORS regression, not a transient network failure.
  • Log every rejected origin server-side (origin not in allowlist → 403) with the origin value. Sudden new origins can reveal misconfigured subdomains or a CDN stripping Origin.
  • Alert when Access-Control-Allow-Origin header is absent from SSE responses in synthetic monitoring; a proxy reconfiguration often strips these silently.

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does EventSource send a preflight OPTIONS request?

No. Native EventSource always issues a simple CORS GET with no preflight, because it cannot send custom request headers and always uses GET. A preflight only appears when you switch to a fetch()-based client with non-simple headers like Authorization. In that case you must add an OPTIONS handler that returns 204 with the required Access-Control-Allow-* headers.

Why can't I use Access-Control-Allow-Origin: * with credentials?

The Fetch specification (section 3.2.5) explicitly forbids this combination. If the browser receives Access-Control-Allow-Credentials: true alongside Access-Control-Allow-Origin: *, it treats the CORS check as failed and blocks the response. You must reflect the exact requesting origin. Use a server-side allowlist and set Vary: Origin so caches treat responses as origin-specific.

My Nginx proxy strips the Origin header before my app sees it — how do I fix it?

Add proxy_set_header Origin $http_origin; to the Nginx location block. Without this, Nginx forwards the request without the Origin header, so your app cannot validate the origin or set the correct Access-Control-Allow-Origin value. Also ensure proxy_buffering off; is set so SSE events are forwarded immediately rather than accumulated.

What readyState does EventSource enter when CORS fails?

EventSource fires an error event and moves directly to readyState === 2 (CLOSED). It does not schedule a reconnect — the CORS failure is treated as a fatal error, not a transient network outage. You must fix the server headers; no client-side retry logic can bypass a CORS block.

Does Last-Event-ID need special CORS treatment?

Yes. On reconnect, the browser sends Last-Event-ID as a request header. For native EventSource this is still a simple CORS request (the spec lists Last-Event-ID as a CORS-safelisted request header). For fetch-based clients, if you include Last-Event-ID in a preflight, include it in Access-Control-Allow-Headers on the OPTIONS response. See Event ID & Retry Mechanism Design for how the reconnect handshake works.

⚡ Production Directives

  • Always reflect the exact Origin from the request rather than using *; validate against a server-side allowlist and set Vary: Origin on every SSE response.
  • Set proxy_buffering off and proxy_set_header Origin $http_origin in every Nginx location block that proxies an SSE endpoint.
  • Add an OPTIONS handler that returns 204 with Access-Control-Max-Age: 86400 if any client uses fetch with custom headers — this eliminates repeat preflights for 24 hours.
  • Monitor for SSE onerror events that immediately close (readyState === 2); they signal a CORS failure at the proxy or header level, not a transient network issue.
  • Include CORS header assertions in CI integration tests so a proxy config change cannot silently break SSE in production.