GraphQL subscriptions are where CORS advice usually gets sloppy.

A lot of teams learn CORS from regular fetch() requests, then bolt on subscriptions and assume the same rules apply. They do not. The transport matters:

  • HTTP GraphQL queries/mutations: normal CORS rules
  • WebSocket subscriptions: not governed by browser CORS in the same way
  • SSE subscriptions: back to normal HTTP-origin behavior
  • Multipart/deferred streaming over HTTP: also normal CORS behavior

If you only remember one thing, remember this: GraphQL subscriptions over WebSocket do not use CORS the way fetch() does, but origin validation still matters.

The short version

Use this mental model:

Transport Browser CORS applies? What to validate
HTTP POST /graphql Yes Origin, methods, headers, credentials
WebSocket ws:// / wss:// Not traditional CORS Origin, auth, subprotocol
SSE text/event-stream Yes Same CORS rules as HTTP
GraphQL over HTTP streaming Yes Same CORS rules as HTTP

That difference is why people get confused. They open browser devtools, see an Origin header on a WebSocket handshake, and assume their CORS middleware will protect it. Usually it won’t.

GraphQL subscriptions over WebSocket

Most GraphQL subscription stacks use one of these:

  • graphql-ws
  • older subscriptions-transport-ws
  • framework wrappers around the same protocol

The browser starts with an HTTP upgrade request:

GET /graphql HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: graphql-transport-ws
Origin: https://app.example.com

You’ll notice Origin is there. Good. Use it.

What CORS middleware gets wrong here

Typical Express CORS middleware only handles normal HTTP requests. A WebSocket upgrade often bypasses that middleware entirely.

So this is not enough:

import express from "express";
import cors from "cors";

const app = express();

app.use(cors({
  origin: ["https://app.example.com"],
  credentials: true,
}));

That protects your HTTP routes. It does nothing useful for many WebSocket upgrade paths.

Safe WebSocket origin validation

Here is the pattern I actually recommend: validate Origin during the WebSocket upgrade.

Node.js with ws

import http from "http";
import express from "express";
import { WebSocketServer } from "ws";

const app = express();

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

app.use(express.json());

app.post("/graphql", (req, res) => {
  res.json({ ok: true });
});

const server = http.createServer(app);

const wss = new WebSocketServer({
  noServer: true,
});

server.on("upgrade", (req, socket, head) => {
  const origin = req.headers.origin;
  const protocol = req.headers["sec-websocket-protocol"];

  if (!origin || !allowedOrigins.has(origin)) {
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    socket.destroy();
    return;
  }

  if (protocol !== "graphql-transport-ws") {
    socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req);
  });
});

wss.on("connection", (ws, req) => {
  console.log("WebSocket connected from", req.headers.origin);

  ws.on("message", (msg) => {
    console.log(msg.toString());
  });

  ws.send(JSON.stringify({
    type: "connection_ack",
  }));
});

server.listen(4000);

That’s the baseline:

  • allow only known origins
  • allow only the expected GraphQL subprotocol
  • reject everything else early

If you skip origin validation, any site can try to open a WebSocket to your backend from a victim’s browser.

Credentials and cookies with WebSocket subscriptions

This is where teams get burned.

Browsers can send cookies during a WebSocket handshake if the cookie policy allows it. That means a malicious origin might be able to trigger an authenticated socket unless you validate Origin and use proper cookie settings.

For cookie-based auth:

  • validate Origin on every upgrade
  • use SameSite=Lax or SameSite=Strict where possible
  • use SameSite=None; Secure only when cross-site usage is actually required
  • don’t trust cookies alone for subscription authorization

I prefer explicit token auth for subscriptions.

Example: auth token in connection_init

With graphql-ws, clients commonly send auth in the first message:

import { createClient } from "graphql-ws";

const client = createClient({
  url: "wss://api.example.com/graphql",
  connectionParams: async () => ({
    authorization: `Bearer ${localStorage.getItem("access_token")}`,
  }),
});

Then validate it server-side after the socket connects:

function handleConnectionInit(message) {
  if (message.type !== "connection_init") {
    throw new Error("Expected connection_init");
  }

  const auth = message.payload?.authorization;
  if (!auth || !auth.startsWith("Bearer ")) {
    throw new Error("Missing token");
  }

  return auth.slice("Bearer ".length);
}

My rule: Origin check protects the browser boundary; token auth protects the user/session boundary. You want both.

SSE subscriptions and CORS

Some GraphQL servers support subscriptions over Server-Sent Events instead of WebSocket. SSE is just HTTP with a long-lived response, so normal CORS rules apply.

That means:

  • the browser enforces Access-Control-Allow-Origin
  • preflights may happen depending on headers
  • credentials require Access-Control-Allow-Credentials: true
  • wildcard origin and credentials cannot be combined

Express example for SSE GraphQL endpoint

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = new Set([
  "https://app.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  }

  if (req.method === "OPTIONS") {
    return res.sendStatus(204);
  }

  next();
});

app.get("/graphql/stream", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");

  res.write(`event: next\n`);
  res.write(`data: {"data":{"message":"hello"}}\n\n`);

  const interval = setInterval(() => {
    res.write(`event: next\n`);
    res.write(`data: {"data":{"time":"${new Date().toISOString()}"}}\n\n`);
  }, 5000);

  req.on("close", () => clearInterval(interval));
});

app.listen(4000);

Client example with credentials

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

es.onmessage = (event) => {
  console.log(event.data);
};

For this to work cross-origin, the server must return:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Not *.

Apollo Server and framework setups

Frameworks hide details, which is convenient until subscriptions fail in production.

The common failure modes:

  1. HTTP CORS is configured, WebSocket origin checks are missing
  2. Localhost is allowed broadly and accidentally ships to production
  3. Credentials are enabled with wildcard origin
  4. Reverse proxy strips or rewrites Origin
  5. WebSocket endpoint and HTTP endpoint have different policies

A sane production policy looks like this:

const allowedOrigins = [
  "https://app.example.com",
  "https://admin.example.com",
];

Not this:

origin: true

And definitely not this in production:

origin: "*",
credentials: true

That combination is invalid for browser CORS anyway.

Reverse proxies matter

If you terminate TLS or handle upgrades in Nginx, Traefik, or a cloud load balancer, make sure the proxy passes WebSocket upgrades correctly and doesn’t undermine your checks.

Nginx example

location /graphql {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header Origin $http_origin;
}

Your app should still validate Origin. The proxy forwarding it is not the same as enforcing it.

Debugging checklist

When subscriptions fail, I usually separate the problem by transport first.

If it’s WebSocket

Check:

  • is the browser sending Origin?
  • is the server validating Origin?
  • is the expected subprotocol set? usually graphql-transport-ws
  • are cookies being sent unexpectedly?
  • is the proxy forwarding upgrade headers?

Browser symptom patterns:

  • handshake rejected with 403: likely origin policy
  • handshake rejected with 400: often protocol mismatch
  • socket opens then immediately closes: auth or GraphQL protocol issue

If it’s SSE or HTTP streaming

Check:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials
  • Vary: Origin
  • Access-Control-Allow-Headers
  • preflight OPTIONS handling

Use browser devtools and inspect the actual response headers. Most “CORS bugs” are just missing headers on error responses or preflight routes.

Copy-paste policy recipes

Strict single-origin WebSocket + HTTP

const allowedOrigin = "https://app.example.com";

function isAllowedOrigin(origin) {
  return origin === allowedOrigin;
}

Dynamic allowlist with credentials

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://staging.example.com",
]);

function applyCors(req, res) {
  const origin = req.headers.origin;
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
}

Reject missing origin for browser-facing WebSocket endpoints

function validateWsOrigin(req) {
  const origin = req.headers.origin;
  if (!origin) return false;
  return allowedOrigins.has(origin);
}

I’m stricter on browser endpoints because “no Origin” usually means a non-browser client, script, or proxy. If you want to allow those, do it intentionally.

Best practices

  • Treat HTTP CORS and WebSocket origin checks as separate controls
  • Validate Origin on every WebSocket upgrade
  • Use explicit allowlists, not reflection-by-default
  • Keep HTTP and subscription origin policies aligned
  • Prefer token auth for subscriptions
  • Be careful with cookie-authenticated sockets
  • Return Vary: Origin when dynamically allowing origins
  • Don’t mix Access-Control-Allow-Origin: * with credentials
  • Test through the real proxy/CDN path, not just localhost

If you’re also tightening broader browser-side protections, pair this with sensible security headers. For that side of the stack, https://csp-guide.com is a useful reference. For GraphQL server behavior and protocol specifics, stick to your framework’s official docs and the official WebSocket and Fetch documentation.