CORS with GraphQL looks simple right up until the browser starts throwing vague errors and your API “works in curl” but fails in production.
I’ve seen this a lot with Apollo Server because GraphQL teams tend to focus on schema design and resolvers, then treat HTTP as plumbing. Browsers do not care how elegant your schema is. If your CORS policy is wrong, the app breaks anyway.
Here are the mistakes I see most often with Apollo Server, why they happen, and how to fix them without turning your API into Access-Control-Allow-Origin: * soup.
Mistake #1: Assuming GraphQL is “just POST” so CORS is trivial
A lot of teams assume GraphQL requests are simple cross-origin POSTs. They usually are not.
A browser request becomes a preflighted CORS request when you send:
Content-Type: application/jsonAuthorization: Bearer ...- custom headers like
apollographql-client-name - credentials like cookies
That means the browser first sends OPTIONS and expects valid CORS headers before it even sends the real GraphQL request.
A typical Apollo client request triggers preflight immediately:
await fetch("https://api.example.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token"
},
body: JSON.stringify({
query: "{ viewer { id } }"
})
});
If your server handles POST /graphql but forgets OPTIONS /graphql, the browser blocks it.
Fix
Make sure your server answers preflight requests on the GraphQL endpoint and returns the right headers.
With Apollo Server on Express:
import express from "express";
import cors from "cors";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
const app = express();
const corsOptions = {
origin: ["https://app.example.com"],
methods: ["POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true
};
app.use("/graphql", cors(corsOptions));
app.options("/graphql", cors(corsOptions));
app.use("/graphql", express.json());
const server = new ApolloServer({
typeDefs,
resolvers
});
await server.start();
app.use("/graphql", expressMiddleware(server));
If you skip app.options("/graphql", cors(corsOptions)), some setups still work because middleware catches it, but I prefer being explicit. It saves debugging time later.
Mistake #2: Using * with credentials
This is probably the most common Apollo CORS bug.
People want cookie-based auth from https://app.example.com, so they configure:
{
origin: "*",
credentials: true
}
Browsers reject that. If credentials are included, Access-Control-Allow-Origin cannot be *.
This is one of those cases where the browser is stricter than your backend framework. Your server may happily send the headers. The browser still blocks the response.
For public APIs, wildcard origin is fine. GitHub’s API is a good example of that style:
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, Warning
That works because the API is designed for broad cross-origin access and not browser cookies.
Fix
If you need cookies or other credentials, return the exact allowed origin.
const corsOptions = {
origin: ["https://app.example.com"],
credentials: true
};
If you need multiple frontends:
const allowedOrigins = new Set([
"https://app.example.com",
"https://studio.example.com"
]);
const corsOptions = {
origin(origin, callback) {
if (!origin) return callback(null, false); // non-browser or same-origin
if (allowedOrigins.has(origin)) {
return callback(null, origin);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true
};
Be careful with “reflect any origin” logic. If you just echo back whatever Origin the client sends, you effectively disabled origin protection.
Mistake #3: Forgetting that Apollo Studio and local dev are different origins
Apollo Server projects often have at least three browser clients hitting the same endpoint:
- local frontend like
http://localhost:3000 - production app like
https://app.example.com - Apollo Studio Explorer
Teams allow one of them and break the others.
Fix
List the actual origins you support. Don’t guess.
const allowedOrigins = [
"http://localhost:3000",
"https://app.example.com",
"https://studio.apollographql.com"
];
app.use("/graphql", cors({
origin(origin, cb) {
if (!origin) return cb(null, false);
if (allowedOrigins.includes(origin)) return cb(null, true);
return cb(new Error(`Origin ${origin} not allowed`));
},
credentials: true
}));
I like logging blocked origins in development because CORS failures are otherwise miserable to debug.
origin(origin, cb) {
if (!origin) return cb(null, false);
if (allowedOrigins.includes(origin)) return cb(null, true);
console.warn("Blocked by CORS:", origin);
return cb(new Error("Not allowed by CORS"));
}
Mistake #4: Not allowing the headers your GraphQL client actually sends
Apollo Client and browser apps often send more than Content-Type.
Common examples:
AuthorizationApollo-Require-Preflightapollographql-client-nameapollographql-client-version- CSRF-related headers in custom setups
If the browser preflight asks for a header and your server doesn’t allow it, the request never reaches your resolver.
Fix
Allow the headers you expect, and keep the list honest.
app.use("/graphql", cors({
origin: "https://app.example.com",
credentials: true,
methods: ["POST", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"Apollo-Require-Preflight",
"apollographql-client-name",
"apollographql-client-version"
]
}));
A lazy workaround is to reflect requested headers dynamically. I try not to do that unless I trust every caller and understand the tradeoff.
Mistake #5: Exposing too few response headers
This one is subtle. Your GraphQL request succeeds, but frontend code cannot read useful headers from the response.
By default, browsers only expose a limited set of response headers to JavaScript. If you want the client to read things like rate limits, pagination hints, request IDs, or ETags, you need Access-Control-Expose-Headers.
GitHub does this well. Their API exposes a long list of operationally useful headers, including:
ETagLinkRetry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetX-GitHub-Request-Id
Fix
Expose the headers your frontend actually uses.
app.use("/graphql", cors({
origin: "https://app.example.com",
credentials: true,
exposedHeaders: [
"ETag",
"Link",
"Retry-After",
"X-Request-Id",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset"
]
}));
If your frontend has retry logic or support tooling, exposing request IDs is especially useful.
Mistake #6: Applying CORS too broadly across the whole app
A lot of Express apps do this:
app.use(cors());
That enables CORS for everything: GraphQL, health checks, internal admin routes, maybe even debug endpoints you forgot existed.
That is usually sloppier than necessary.
Fix
Scope CORS to the routes that need cross-origin browser access.
app.use("/graphql", cors(corsOptions));
app.use("/graphql", express.json());
app.use("/graphql", expressMiddleware(server));
app.get("/healthz", (req, res) => {
res.send("ok");
});
If /healthz does not need browser access from another origin, don’t add CORS there.
Mistake #7: Treating CORS as authentication
CORS is a browser access control mechanism. It is not API auth.
If your GraphQL endpoint accepts requests from mobile apps, server-side code, CLI tools, or curl, those clients do not care about browser CORS enforcement. They can still send requests.
I still see teams say “the API is safe because only our frontend origin is allowed.” No. Your origin allowlist only tells browsers which pages may read responses. It does not secure the API by itself.
Fix
Use real authentication and authorization in resolvers or middleware:
const server = new ApolloServer({
typeDefs,
resolvers
});
app.use("/graphql", expressMiddleware(server, {
context: async ({ req }) => {
const auth = req.headers.authorization || "";
const user = await getUserFromAuthHeader(auth);
if (!user) {
throw new Error("Unauthenticated");
}
return { user };
}
}));
CORS is one layer. Auth is a different layer. Keep them mentally separate.
Mistake #8: Ignoring Vary: Origin when responses are cached
If your server returns different Access-Control-Allow-Origin values depending on the request origin, caches need to know that.
Without Vary: Origin, a CDN or proxy can cache a response for one origin and serve it to another with the wrong CORS headers.
Fix
Most decent CORS middleware handles this, but verify it in real responses. If you’re doing manual headers, add it yourself:
app.use("/graphql", (req, res, next) => {
res.header("Vary", "Origin");
next();
});
This matters more once you put Apollo behind a CDN or reverse proxy.
A baseline Apollo CORS setup that doesn’t fight the browser
If I were setting up a cookie-authenticated GraphQL API today, I’d start here:
import express from "express";
import cors from "cors";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
const app = express();
const allowedOrigins = new Set([
"http://localhost:3000",
"https://app.example.com"
]);
const corsOptions = {
origin(origin, cb) {
if (!origin) return cb(null, false);
if (allowedOrigins.has(origin)) return cb(null, true);
return cb(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["POST", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"Apollo-Require-Preflight",
"apollographql-client-name",
"apollographql-client-version"
],
exposedHeaders: [
"ETag",
"X-Request-Id",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset"
]
};
app.use("/graphql", cors(corsOptions));
app.options("/graphql", cors(corsOptions));
app.use("/graphql", express.json());
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use("/graphql", expressMiddleware(server));
app.listen(4000);
That setup is boring, which is exactly what you want from CORS.
If you’re tightening security beyond CORS, look at the rest of your browser-facing headers too, especially CSP for GraphQL playgrounds and embedded tools. If you need a practical guide for that side of things, csp-guide.com is worth keeping around.