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 of EventSource

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:

  1. Use cookie-based auth with withCredentials: true
  2. Put a short-lived token in the URL query string
  3. Replace EventSource with fetch() 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:

  1. Check the actual request origin
  2. Check whether withCredentials is enabled
  3. Verify Access-Control-Allow-Origin exactly matches the page origin when credentials are used
  4. Verify Access-Control-Allow-Credentials: true is present for credentialed requests
  5. Verify cookies are actually being sent
  6. Make sure the response is really text/event-stream
  7. 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 Origin if 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:

  • EventSource for 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.