Server-Sent Events look deceptively simple. Open a stream, keep writing data: lines, and the browser keeps listening. Then you put that stream on another origin and suddenly you’re debugging CORS, cookies, proxies, and browser quirks at 2 a.m.
I’ve hit this enough times that I now treat SSE as “simple transport, annoying edge cases.” The CORS part is one of those edge cases.
What CORS means for SSE
SSE uses the browser’s EventSource API:
const source = new EventSource("https://api.example.com/events");
That request is still subject to the browser’s same-origin policy. If your page is served from:
https://app.example.com
and your SSE endpoint is:
https://api.example.com/events
the browser treats that as cross-origin, so the server must allow it with CORS.
For a basic cross-origin SSE connection, the server usually needs:
Access-Control-Allow-Origin: https://app.example.com
Content-Type: text/event-stream
Cache-Control: no-cache
If you want to allow any origin and you are not using credentials:
Access-Control-Allow-Origin: *
That’s the same pattern you’ll see on public APIs. For example, api.github.com returns:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That access-control-expose-headers list is a good reminder that CORS is not only about whether the request is allowed. It also controls what response metadata browser JavaScript can access. SSE itself doesn’t give you direct access to response headers through EventSource, but the broader CORS model still matters if your app mixes SSE with fetch() calls to the same API.
The annoying part: SSE is a GET, but credentials change everything
A plain EventSource request is a GET, and in the simple case it does not trigger a CORS preflight.
That makes people assume SSE is always easy with CORS. It isn’t.
The main fork in the road is whether you need credentials.
Without credentials
const source = new EventSource("https://api.example.com/events");
Server:
Access-Control-Allow-Origin: https://app.example.com
Content-Type: text/event-stream
Or:
Access-Control-Allow-Origin: *
if the stream is fully public.
With credentials
If your SSE endpoint relies on cookies or HTTP auth, the browser request must be created with credentials enabled:
const source = new EventSource("https://api.example.com/events", {
withCredentials: true
});
Then your server must return:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
And this is where people break it:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination is invalid for credentialed CORS. Browsers reject it.
If you need cookies, you must echo a specific origin, not *.
A minimal Node.js SSE server with CORS
Here’s a plain Express example that works for cross-origin SSE.
const express = require("express");
const app = express();
app.get("/events", (req, res) => {
const origin = req.headers.origin;
const allowedOrigins = new Set([
"https://app.example.com",
"https://dashboard.example.com"
]);
if (allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
// Uncomment if you use cookies/auth
// res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
// Flush headers early
res.flushHeaders?.();
res.write(`event: welcome\n`);
res.write(`data: ${JSON.stringify({ ok: true })}\n\n`);
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
}, 5000);
req.on("close", () => {
clearInterval(interval);
res.end();
});
});
app.listen(3000, () => {
console.log("SSE server listening on http://localhost:3000");
});
Client:
const source = new EventSource("http://localhost:3000/events");
source.onmessage = (event) => {
console.log("message", JSON.parse(event.data));
};
source.addEventListener("welcome", (event) => {
console.log("welcome", JSON.parse(event.data));
});
source.onerror = (err) => {
console.error("SSE error", err);
};
Credentialed SSE with cookies
If your auth model uses a session cookie, the browser and server both need to cooperate.
Client:
const source = new EventSource("https://api.example.com/events", {
withCredentials: true
});
Server:
app.get("/events", (req, res) => {
const origin = req.headers.origin;
if (origin === "https://app.example.com") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
// Validate session cookie here
const authenticated = Boolean(req.headers.cookie?.includes("session="));
if (!authenticated) {
return res.status(401).end();
}
res.write(`data: ${JSON.stringify({ user: "alice" })}\n\n`);
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ heartbeat: Date.now() })}\n\n`);
}, 15000);
req.on("close", () => {
clearInterval(interval);
res.end();
});
});
Also remember the cookie itself must be usable cross-site if the page and API are on different sites. That usually means:
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None
If you forget SameSite=None; Secure, the browser may silently omit the cookie and your SSE auth “mysteriously” fails.
Does SSE need preflight?
Usually no. EventSource makes a GET request with browser-controlled headers, so it generally falls under the simple request path.
But there are practical caveats:
- You can’t set arbitrary request headers on native
EventSource - That means bearer-token-in-header auth is awkward with native SSE
- If you need custom headers, many teams switch to
fetch()plus a streaming response instead ofEventSource
That’s not really a CORS bug. It’s an API design constraint.
If you’re thinking, “I’ll send Authorization: Bearer ... with EventSource,” you can’t do that with the native browser API.
Common workarounds:
- Use cookie-based auth with
withCredentials: true - Put a short-lived token in the URL query string
- Replace
EventSourcewithfetch()streaming
Option 2 works, but I don’t love it unless the token is tightly scoped and short-lived. URLs leak into logs, analytics systems, browser history, and reverse proxy traces.
A fetch-based streaming alternative
If you need custom headers and full control, fetch() is often better than EventSource.
async function connect() {
const response = await fetch("https://api.example.com/events", {
headers: {
Authorization: "Bearer YOUR_TOKEN"
},
credentials: "include"
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const chunk = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
console.log("raw event chunk:", chunk);
}
}
}
connect().catch(console.error);
Now your request may trigger preflight, because you’re sending Authorization. Your server would need the usual CORS preflight handling:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Methods: GET, OPTIONS
That’s one reason native SSE can feel easier until it suddenly isn’t.
Reverse proxies can break perfectly good SSE
A lot of “CORS issues” are actually proxy issues that just look similar from the frontend.
Nginx, CDNs, and load balancers may buffer or transform the response unless you tell them not to. For SSE, buffering is poison.
Typical Nginx config:
location /events {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
chunked_transfer_encoding off;
proxy_cache off;
}
And from your app, send:
Cache-Control: no-cache, no-transform
Content-Type: text/event-stream
If the browser connects but messages arrive in giant delayed batches, that’s usually buffering.
How to debug CORS for SSE without guessing
The browser console error is often vague. You’ll get something like “blocked by CORS policy,” which tells you almost nothing useful.
My usual checklist:
- Check the actual request origin
- Check whether
withCredentialsis enabled - Verify
Access-Control-Allow-Originexactly matches the page origin when credentials are used - Verify
Access-Control-Allow-Credentials: trueis present for credentialed requests - Verify cookies are actually being sent
- Make sure the response is really
text/event-stream - Make sure a proxy isn’t buffering the stream
For quick header inspection, I like using HeaderTest to sanity-check what the server is actually returning. Half the time the bug is just “the header you thought was there is not there in production.”
Security notes people skip
CORS is not an auth system. Allowing an origin does not mean the user should get the stream.
Your SSE endpoint still needs proper authentication and authorization.
A few practical rules:
- Don’t use
Access-Control-Allow-Origin: *on private streams - Don’t combine wildcard origin with credentials
- Be careful with token-in-query auth
- Validate
Originif you’re doing credentialed browser access - Keep session cookies
HttpOnly - Use TLS only
If you’re reviewing the rest of your response headers too, that’s separate from CORS. Things like CSP, HSTS, and framing controls live in a different part of the security model. If you need a refresher on that side, csp-guide.com is useful.
A production-safe pattern
If I were wiring this up for a real app, I’d usually do this:
EventSourcefor browser simplicity- Cookie-based session auth
withCredentials: true- Explicit allowlist of frontend origins
Vary: Origin- Proxy buffering disabled
- Heartbeat events every 15–30 seconds
- Reconnect support with event IDs if message continuity matters
Example heartbeat:
setInterval(() => {
res.write(`event: ping\n`);
res.write(`data: {}\n\n`);
}, 20000);
And if you care about resumability:
res.write(`id: 123\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
The browser will send Last-Event-ID on reconnect, which lets your server resume from the right point.
That part isn’t CORS-specific, but it matters because cross-origin SSE failures often reconnect repeatedly. If your stream isn’t designed for reconnects, the user experience gets ugly fast.
SSE with CORS is manageable once you stop treating it like plain old HTTP. It’s a long-lived browser-controlled cross-origin request. That combination has rules, and browsers are not forgiving when you get them wrong.