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
Originon every upgrade - use
SameSite=LaxorSameSite=Strictwhere possible - use
SameSite=None; Secureonly 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:
- HTTP CORS is configured, WebSocket origin checks are missing
- Localhost is allowed broadly and accidentally ships to production
- Credentials are enabled with wildcard origin
- Reverse proxy strips or rewrites
Origin - 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-OriginAccess-Control-Allow-CredentialsVary: OriginAccess-Control-Allow-Headers- preflight
OPTIONShandling
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
Originon 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: Originwhen 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.