Skip to main content
← Back to Blog
Engineering

How Keygraph Secures GraphQL in a Multi-Tenant Product

About Keygraph. Keygraph is a continuous, agentic application security platform. It runs autonomous penetration tests, agentic static analysis, and dependency and secrets scanning across the development pipeline, and it ships confirmed, exploitable findings (with fixes delivered as pull requests) instead of a wall of pattern-matched noise. You can read more at keygraph.io.

Keygraph itself runs on GraphQL. Our entire product API, the one every customer's dashboard, integration, and workflow talks to, is a single multi-tenant GraphQL gateway. So securing GraphQL isn't an abstract exercise for us; it's the front door to our own platform. And because we sell application security, that front door has to meet the same standard we hold everyone else's code to. This post is how we do it.


GraphQL gives clients a remarkable amount of power. One endpoint, and the caller decides the exact shape of the data they want back. That flexibility is why teams love GraphQL and why we chose it. It is also exactly what makes it hard to secure. In a multi-tenant product like ours, where every customer's data sits behind that same endpoint, the margin for error is thin. A single query that crosses a tenant boundary is the whole ballgame.

GraphQL's flexibility is its attack surface

The same property that makes GraphQL pleasant to build against, the client composing its own query, is the one an attacker reaches for first. If callers can ask for anything, then "anything" is your attack surface. They can explore the schema to learn what exists, craft queries designed to be expensive, and probe for fields they shouldn't be able to reach.

In a multi-tenant setting there's a sharper edge on top of that. Authentication proves who you are, but on its own that isn't enough. The hard question is whose data a well-formed, fully authenticated request is allowed to touch. Most of what follows is really about answering that question the same way every single time.

The threat model, briefly

It helps to name what we're defending against. The GraphQL-specific risks cluster into a handful of families (the OWASP GraphQL Cheat Sheet and Arcjet's write-up, both linked at the end, are good deep dives):

  • Reconnaissance: introspection and exploratory tooling that map the schema, plus error hints that leak field names.
  • Denial of service: deeply nested queries, recursive or circular selections, aliasing and duplication, and batching many operations into one request to amplify cost or dodge per-request limits.
  • Broken authorization: object-level and function-level access failures, like reaching a record or an operation you shouldn't, often by supplying an ID directly (the multi-tenant IDOR).
  • Injection: unsanitized inputs flowing into SQL, OS commands, or other interpreters.
  • Request forgery and info disclosure: riding a logged-in browser session to perform actions, and error responses that hand back internal detail.

The rest of the post maps our defenses onto these families.

Our architecture in one picture

A request enters through our edge router, reaches the GraphQL gateway (built on GraphQL Yoga with a Pothos-defined schema on top of Express), and the gateway calls into our Go backend services over Connect-RPC. Authorization decisions are delegated to a dedicated relationship-based permission service. Data lives in Postgres, accessed only through the backend services.

can this action run?

authorized, tenant-scoped request

GraphQL gateway · Yoga + Pothos

1 · Run only known operations

2 · Authenticate session

3 · Derive tenant from token

4 · Check permission per field

Client / Browser

Edge router

Go backend services

Postgres

Authorization service
relationship-based permissions

The request path: filtered, authenticated, tenant-scoped, and authorized at the gateway before anything reaches a backend service.

The important structural point is that the gateway is a boundary, not a place where business logic or database access lives. It authenticates, authorizes, enforces tenancy, and hands a trusted, scoped request to the services behind it. That separation is what lets the security controls below stay consistent instead of being sprinkled through resolver code. The four numbered steps inside the gateway are exactly the layers we walk through next.


Defense, layer by layer

A GraphQL endpoint is a single door that accepts a nearly infinite variety of requests. Most defenses in this space try to make that door safe while leaving it wide open. They inspect each query, score its complexity, and reject the ones that look expensive. That works, but it means you're forever writing rules to catch a shape you didn't anticipate. We took a different starting point, then layered the familiar controls behind it.

1. We don't accept arbitrary queries in production

The single most important thing about our gateway is what it won't do. In production it will not execute a GraphQL query it has never seen before.

Our clients don't send raw query text. During the build, every operation the frontend is allowed to run is extracted and registered, and at runtime the client sends a reference to one of those pre-registered operations instead of an ad-hoc document. If a request asks for an operation that isn't on the list, it never reaches a resolver. The technique has a few names in the wild, persisted queries or trusted documents, but the security idea is the same: the server keeps a list of the exact operations it will run, and everything else is refused.

The difference from the usual model is the whole point:

Allowlist · our approach

yes

no

Reference to a
known operation

On the
allowlist?

Run

Rejected before
a resolver

Inspect and score · open surface

under budget

over budget

Any query the
client writes

Score cost
and depth

Run

Reject

Inspect-and-score defends an open surface. The allowlist removes the open surface entirely.

In the inspect-and-score model on the left, arbitrary queries reach the server and you rely on getting the scoring right. On the right, the set of runnable queries is fixed ahead of time, so the dangerous shapes never enter the building.

This is usually described as a performance optimization. For us it's primarily a security decision, because an allowlist of known operations quietly removes whole families of the threat model at once:

  • Introspection and schema recon. There's no arbitrary query to introspect with, so the exploratory tooling attackers use to map a schema returns nothing useful.
  • Deep and nested query denial-of-service. A hand-crafted recursive query isn't on the allowlist, so it can't be run to exhaust the server.
  • Field-suggestion enumeration. The "did you mean…?" hints that leak field names on a typo never fire, because clients aren't typing fields.

Instead of writing rules to catch malicious query shapes, we shrank the set of runnable shapes to the ones we shipped on purpose. That's the lever the rest of this post is built on.

2. Tenancy is decided by the token, never by the request

Keygraph is multi-tenant. Every customer's data lives behind the same API, and the entire security model rests on one invariant: a request can only ever act within the tenant its credential belongs to.

The failure mode we design against is the classic one: a request that is perfectly authenticated but tries to name someone else's tenant. If the server ever takes the tenant identity from the request body, an authenticated user in tenant A can ask for tenant B's records. This is the multi-tenant version of an IDOR, and it's the bug we care about most.

Our rule is simple and applied at the gateway. The tenant a request runs as is derived from the verified session token, not from anything the caller can type. Arguments describe what you want, like a document, a device, or a finding. They never get to redefine whose it is. Downstream services receive the tenant identity from that trusted context and scope every database query to it.

We make this structural: encoding the boundary as a shared mechanism instead of a per-endpoint habit is what keeps it reliable, and it's a theme we return to at the end.

3. Authorization is a property of the schema

Authentication tells us who you are. Authorization decides what you're allowed to do, and in a GraphQL API that has to be answered at the granularity of individual fields, not whole endpoints.

We attach authorization declaratively to the schema itself. Every operation that touches customer data carries the permission it requires as part of its definition, and the gateway enforces that requirement before the resolver runs. Two things fall out of doing it this way:

  • Coverage is auditable. Because the requirement lives on the field, we can mechanically check that protected operations actually declare a permission, rather than hoping every author remembered to add a check inside their resolver.
  • The decision is centralized. The actual "can this subject perform this action in this tenant?" question is answered by a dedicated authorization service built on a relationship-based permission system, so complex access rules like teams, roles, ownership, and delegated access all live in one place instead of being reimplemented per resolver.

This maps directly onto the access-control failures OWASP warns about, broken object-level and function-level authorization, by making the check a structural part of every field rather than a line of code someone might forget.

4. A request can't lie about which operation it is

There's a subtle attack that allowlisting alone doesn't close. Some operations are intentionally public, like signing in or refreshing a session, and an attacker would love to make a different, sensitive request masquerade as one of those to slip past authentication.

We close that gap by deriving an operation's identity from the registered operation it references, the thing we can trust, rather than from a free-text label attached to the request. A caller can't take a privileged request and relabel it as "refresh my session" to ride the public path, because the label isn't what we consult. It's a small piece of plumbing, but it's the kind of detail that decides whether an allowlist is actually airtight or just looks like it.

Because browser sessions ride on cookies, state-changing operations need protection against cross-site request forgery, where a malicious page tries to make a logged-in user's browser perform an action without their intent. We apply a standard double-submit defense to mutations: the request has to prove it originated from our own application. Read-only operations skip this check; only requests that change something have to prove where they came from.

6. Rate limiting in layers

Not every abuse case is a clever query. Some are just volume. We rate-limit from broad to specific. A general limit across the gateway blunts brute-force and scraping, and tighter, purpose-built limits guard the operations that are attractive to abuse on their own, like authentication attempts and anything that sends an email, where the risk is credential-stuffing or using our system to spam an inbox. The sensitivity of an operation, not just its raw frequency, shapes how hard we throttle it.

7. Errors that don't teach the attacker, and a trail of what happened

Two final layers, both about what happens around a request. First, error handling: errors are mapped to a typed, client-appropriate shape, so a failure tells a legitimate client what it needs to fix without handing an attacker internal detail to pivot on. Second, audit logging: state-changing operations are recorded (who did what, to which resource, in which tenant), including when the actor is operating on behalf of someone else.


The honest part: our tradeoff, and what we're hardening

If you've read a few GraphQL security posts, you'll have noticed we skipped the one everybody leads with: query cost analysis. No paragraph about pricing each field and rejecting queries over a budget, no depth-limit middleware. That omission is deliberate.

Query-cost analysis exists to make an open query surface safe. If a client can send any query, you need a way to price arbitrary queries and refuse the expensive ones. We made a different bet earlier in the stack. Because we only run pre-registered operations, the open-ended surface those tools defend mostly isn't there. An attacker can't submit the pathological 12-level nested query, because it was never registered. For a long time, the allowlist was our depth-and-complexity defense, and for the biggest GraphQL DoS vectors, it's a strong one.

But "we don't accept arbitrary queries" is not the same sentence as "no expensive query can ever run":

  • An allowlisted operation is still an operation that has to be safe on its own. Approving it during the build means we reviewed it, not that it's incapable of being costly under a hostile input.
  • Volume-based abuse (many cheap requests, aliasing within a permitted document, batching) is a different axis from query shape, and deserves its own limits rather than being assumed away.

Leaning on a single strong control is exactly the anti-pattern defense-in-depth warns about. So we're going belt-and-suspenders. We keep the allowlist as the outer wall, and add the standard query-governance controls underneath it so no single layer is load-bearing:

  • Explicit complexity and depth budgets as a backstop, independent of the allowlist.
  • Alias and batch limits, so a single request can't multiply its own cost or side-step per-operation throttling.
  • Request-level timeouts as an infrastructure floor.
  • Tighter input-validation defaults at the schema boundary.

None of these are exotic. They're the controls the community has converged on. The point of naming them isn't novelty. It's that we'd rather tell you which layers we're still adding than imply the job is finished.

Testing the surface, not just trusting it. Controls are only as good as the evidence that they hold, so we also fuzz the schema in CI. A tool like Schemathesis generates property-based tests straight from the GraphQL schema and throws malformed and edge-case inputs at every field, surfacing unhandled errors and responses that don't match the contract. It runs against the full schema in development, which is where you want to catch a resolver that panics on strange input. In production, only the allowlist runs. Schema-driven fuzzing is a good complement to persisted operations: the allowlist decides what can run in production, and the fuzzer stress-tests everything the schema says is possible before it ships.


The principles underneath

Strip away the GraphQL specifics and three ideas do most of the work.

  • Defense in depth. No single layer is allowed to be the only thing standing between an attacker and your data, which is why the honesty above matters. A strong outer control is a reason to add the next inner one, not a reason to stop.
  • Contracts, enforced mechanically. A GraphQL schema is an API contract. So are our service definitions and our permission model. We lean on tooling to check that protected operations declare their permissions and that changes don't silently break the contract, rather than trusting every author to remember.
  • Make the safe path the easy path. Deriving tenancy from the token, attaching authorization to the field, allowlisting operations at build time: each makes the secure behavior the default, so an individual mistake doesn't quietly open a hole.

That last idea is a statement about engineering culture more than security. We'd rather encode an invariant once, where it's enforced, than rely on everyone re-deriving it correctly under deadline.

We point our own platform at this

There's a nice symmetry in a security company writing this post: the vulnerability classes at the top of this post are exactly what Keygraph is built to find. Broken object-level and function-level authorization, IDOR and tenant-isolation bugs, injection, information disclosure: those are the bread and butter of application security testing, and they don't stop being our problem just because we're the ones writing the API.

So we point our own tooling at it. Shannon, our open-source whitebox AI pentester, is built for web apps and their APIs. It reads the source, plans realistic attack paths, and then executes real exploits against the running application, reporting only the findings it can prove with a working proof-of-concept rather than speculative warnings. Its OWASP-focused coverage (injection, XSS, SSRF, broken authentication, and broken authorization) lines up almost one-to-one with the threat families above, and broken authorization in particular is the multi-tenant IDOR we care about most. The Keygraph platform runs an enhanced Shannon continuously, fed by our own code-analysis stack (Code Property Graph SAST, dependency and secrets scanning, data-flow reachability), so the same class of bug gets caught statically in a resolver and proven dynamically against the deployed API. Dogfooding this against our own GraphQL is how we keep ourselves honest, and it's the most demanding test environment we have.

Further reading