Handling CORS in SSE implementations

Symptom & Developer Intent

When initializing an EventSource connection, browsers immediately block the handshake with:

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.

The intent is to unblock the long-lived HTTP stream while maintaining secure cross-origin boundaries. Unlike stateless REST calls, SSE relies on a persistent GET connection. CORS misconfigurations drop the handshake instantly, breaking real-time data pipelines. For foundational context on how browsers negotiate these persistent streams, review the SSE Protocol Fundamentals & Architecture documentation.

Key Indicators:

Root Cause Analysis

CORS failures in SSE typically stem from three architectural mismatches: missing or wildcard-origin headers on the streaming endpoint, credential handling conflicts, or middleware interception. Browsers enforce strict same-origin policies on text/event-stream responses. When EventSource requests include credentials (cookies, HTTP auth), the server must explicitly echo the requesting origin and set Access-Control-Allow-Credentials: true.

Additionally, the native EventSource constructor does not accept custom headers. Attempting to attach them forces browsers to issue OPTIONS preflight requests that streaming endpoints rarely handle. Many frameworks also buffer responses, stripping or delaying CORS headers until after the first data chunk flushes, which violates the spec. Proper header orchestration requires aligning with Security Headers for Event Streams standards to prevent connection rejection.

Technical Factors:

Step-by-Step Resolution

Resolve CORS blocks by aligning backend header injection with frontend EventSource constraints. Follow this sequence to restore stream connectivity without compromising security boundaries.

1. Configure Backend CORS Headers

Never use * for Access-Control-Allow-Origin when credentials are transmitted. Dynamically reflect the requesting origin and explicitly allow credentials.

Node.js / Express:

app.get('/api/stream', (req, res) => {
 const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
 const requestOrigin = req.headers.origin;

 if (allowedOrigins.includes(requestOrigin)) {
 res.setHeader('Access-Control-Allow-Origin', requestOrigin);
 res.setHeader('Access-Control-Allow-Credentials', 'true');
 } else {
 return res.status(403).end();
 }
 // Proceed with stream setup...
});

2. Handle Preflight & OPTIONS Requests

If your architecture requires custom headers, implement an OPTIONS handler. Alternatively, bypass preflight entirely by moving auth tokens to query parameters, as EventSource only supports standard GET requests without preflight.

Nginx Reverse Proxy Config:

location /api/stream {
 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, Cache-Control' always;
 add_header 'Access-Control-Max-Age' 86400 always;
 return 204;
 }
 proxy_pass http://upstream_sse;
 proxy_set_header Host $host;
}

3. Set Streaming Content-Type Early

Headers must be written to the response buffer before any body data is flushed. Delayed injection causes browsers to misclassify the payload as text/html or application/json, triggering CORS or MIME-type blocks.

Node.js Flush Pattern:

res.writeHead(200, {
 'Content-Type': 'text/event-stream',
 'Cache-Control': 'no-cache',
 'Connection': 'keep-alive',
 'Access-Control-Allow-Origin': requestOrigin,
 'Access-Control-Allow-Credentials': 'true'
});
res.write('\n'); // Force header flush

4. Frontend EventSource Initialization

Initialize with withCredentials: true when using cookies or session auth. If your backend mandates custom headers (e.g., X-API-Key), replace EventSource with a fetch + ReadableStream implementation.

Standard EventSource:

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

source.onopen = () => console.log('Stream established');
source.onmessage = (e) => console.log('Data:', e.data);
source.onerror = (err) => console.error('Connection dropped:', err);

Fetch + ReadableStream (Custom Header Support):

const controller = new AbortController();

fetch('https://api.example.com/api/stream', {
 method: 'GET',
 headers: { 'X-API-Key': 'sk_live_...' },
 signal: controller.signal
}).then(async (response) => {
 const reader = response.body.getReader();
 const decoder = new TextDecoder();
 let buffer = '';

 while (true) {
 const { done, value } = await reader.read();
 if (done) break;
 buffer += decoder.decode(value, { stream: true });
 // Parse SSE format (data: \n\n) from buffer
 const lines = buffer.split('\n');
 buffer = lines.pop() || ''; // Keep incomplete line
 for (const line of lines) {
 if (line.startsWith('data: ')) {
 console.log('Event:', line.slice(6));
 }
 }
 }
}).catch(err => console.error('Stream failed:', err));

Validation & Monitoring

Verify the fix by inspecting network traffic and monitoring stream stability. CORS issues often resurface during deployment or when CDN/proxy layers modify headers.

DevTools Validation Steps:

  1. Open Browser DevTools → Network tab. Filter by EventStream or text/event-stream.
  2. Confirm 200 OK status. Click the request → Headers tab. Verify Access-Control-Allow-Origin exactly matches your frontend domain.
  3. Check Console for zero CORS warnings. Ensure readyState transitions to 1 (OPEN) without retry loops.
  4. Run a manual curl test to bypass browser caching:
curl -I -H "Origin: https://app.example.com" https://api.example.com/api/stream
# Verify: HTTP/1.1 200 OK
# Verify: Access-Control-Allow-Origin: https://app.example.com
# Verify: Content-Type: text/event-stream

Monitoring Metrics & SRE Runbook: