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:
No 'Access-Control-Allow-Origin' header is presentCORS error or blocked GET request with 0 bytes transferredreadyState remains 0 CONNECTING)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:
Access-Control-Allow-Origin (wildcards fail when credentials are used)EventSource constructor does not accept custom headersContent-Type negotiation causing browser to reject streamResolve CORS blocks by aligning backend header injection with frontend EventSource constraints. Follow this sequence to restore stream connectivity without compromising security boundaries.
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...
});
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;
}
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
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));
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:
EventStream or text/event-stream.200 OK status. Click the request → Headers tab. Verify Access-Control-Allow-Origin exactly matches your frontend domain.readyState transitions to 1 (OPEN) without retry loops.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:
EventSource retry counts often signal middleware buffering or CDN interference.Cache-Control: no-cache, no-store, must-revalidate). Cache hits on SSE endpoints will permanently block CORS negotiation.