The gateway that forgot to ask who you are
Big platforms don't run one application. They run dozens, sometimes hundreds, of back-end services behind a single front door called an API gateway. The gateway is where you put the things every service needs done once: terminate TLS, apply rate limits, validate the request shape, and check who the caller is. Do it at the gateway and the services behind it can stay simple.
That last job, checking who the caller is, is where this one fell apart. On a recent assessment of a large SaaS platform, the gateway in front of a GraphQL API validated everything about a request except the thing that mattered. It checked that the query was well-formed. It checked that the fields existed and the argument types were right. It never checked whether the caller was authenticated before routing the request to the back-end.
The back-ends, for their part, assumed the gateway had already done that. So when an unauthenticated request arrived, some services dutifully processed it. Around half of the admin-level mutations we tested went straight through. One of them generated API tokens.
Severity: CVSS 9.1 (Critical) · CWE-862, CWE-942
Exposed: roughly half of the tested admin-level mutations reachable with no authentication, including API-token generation; a reflected-CORS-with-credentials flaw let an attacker fire them from a logged-in victim's browser.
The two assumptions that never met
Every layered system carries a contract about who enforces what. The dangerous failures happen when two layers each assume the other owns a control.
Here the gateway team believed authentication was a back-end concern, because the back-ends are where the business logic and the data live. The back-end teams believed it was a gateway concern, because that's the whole point of putting a gateway in front. Both positions are reasonable in isolation. Together they leave a hole neither team can see from where they sit, because each one is looking at code that does the right thing on its own.
It's the classic confused deputy, just at architectural scale. Nobody wrote if (authenticated == false) allow(). The vulnerability lives in the gap between two components, not inside either one.
What made it weaponizable
An unauthenticated mutation that only works from a server is a serious finding on its own, but it gets far worse when you can fire it from a victim's browser.
The same gateway reflected any origin back in its CORS headers and set Access-Control-Allow-Credentials: true. In plain terms: a random website could make authenticated, cross-origin requests to the API using the visitor's own session, and the browser would allow it. So the attacker didn't even need to be the one authenticated. Put the request on a web page, get a logged-in user to visit, and their browser carries out the privileged action.
Stack the two issues and you get a chain. Where the gateway forgot to check auth, anyone can call the mutation directly. Where a back-end does check, the CORS flaw lets an attacker borrow a real user's session to satisfy it. Between the two, very little of the admin surface is actually protected.
Finding it without a single exploit
No memory corruption here, no clever payload. The work is dull and methodical.
You enumerate the gateway's schema, because GraphQL will usually describe itself if introspection is on, and even when it's off the error messages leak field names. You list the mutations, especially the ones whose names hint at privilege: anything with create, delete, generate, invite, or token in it. Then you send each one with no credentials and read the response code. A 403 means the back-end defends itself. A 200, or a validation error about a missing argument rather than about missing auth, means the request reached application logic. That second case is the finding. The service is talking to you, and it shouldn't be.
# admin mutation with NO auth header still reaches the backend:
$ curl -s https://api.<target>/graphql -H 'content-type: application/json' \
--data '{"query":"mutation{ createApiToken(scope:\"admin\"){ token } }"}'
{"data":{"createApiToken":{"token":"sk_live_********"}}}
# and the gateway reflects any Origin with credentials:
$ curl -sI https://api.<target>/graphql -X OPTIONS -H 'Origin: https://attacker.example'
access-control-allow-origin: https://attacker.example
access-control-allow-credentials: true
The blast radius
When admin mutations are reachable without authentication, the impact is bounded only by which mutations slipped through. Token generation is the worst of them, because a token turns a one-time unauthenticated call into durable, legitimate-looking access. From there an attacker can act as a real integration, read and modify tenant data, and persist long after the original hole is closed. The CORS half widens the target from "an attacker with network access" to "any user who clicks a link."
The fix
- Enforce authentication at the gateway as a default deny, and make every back-end treat an unauthenticated request as impossible to receive. Belt and suspenders. Each layer checks, and neither relies on the other having checked.
- Never reflect arbitrary origins with credentials enabled. Allow a fixed list of known origins, and if a route truly must be public, serve it without credentials.
- Write a contract test that sends every mutation unauthenticated and asserts a 401 or 403. Run it in CI. The gap that opened here is exactly the kind a single automated test would have caught and kept caught.
Prevent it in CI/CD: a contract test that fires every mutation unauthenticated and asserts 401/403 on every build; a fixed CORS allowlist enforced at the gateway; and default-deny authentication at both the gateway and each backend.
Run these checks on your own gateway
- Pull the list of every mutation or write endpoint your gateway exposes. Send each one with no token and record the status. Anything that isn't a clean 401 or 403 is a candidate.
- Check the CORS behavior. Send a request with
Origin: https://example.organd see whether it's reflected and whether credentials are allowed. Reflection plus credentials is its own finding. - Most important: write down which layer owns authentication, and make sure the gateway team and the service teams have read the same sentence. The most expensive gaps are the ones where everyone assumed someone else had it covered.
A gateway exists to answer one question before anything else happens: who is this? When that answer is nobody's job, the request still gets routed. Write down which layer owns the check, test it in CI, and the gap closes. Leave it implicit and it stays open until someone outside finds it for you.