How to Secure GraphQL APIs in a Multi-Tenant SaaS Product
87% of GraphQL vulnerabilities are authorization bypasses. Here's how to secure GraphQL APIs in multi-tenant SaaS: resolver auth, introspection, and DoS.
GraphQL API security is fundamentally different from REST security in one way that catches most engineering teams off guard: a GraphQL API exposes a single endpoint that accepts any query the schema allows. The security question is not which URLs to protect — it is whether the resolver executing each field checks whether the requesting user is authorized to see that data. When resolver-level authorization is missing, every query becomes a potential data exfiltration path, regardless of how strong the route-level middleware is.
In multi-tenant SaaS, the stakes are higher than in single-tenant applications. A single GraphQL endpoint serves all tenants. A resolver that forgets to scope its database query to the requesting user's tenant silently returns another tenant's data — a cross-tenant IDOR that looks like a successful query to the client. The 2026 GraphQL vulnerability landscape reflects this structural risk: 87% of known GraphQL vulnerabilities fall into the authorization bypass class, with the most damaging cases involving cross-tenant data exposure through structurally valid, authenticated queries.
This guide covers the security controls specific to GraphQL in SaaS production: resolver-level authorization patterns, introspection configuration, query complexity limiting to prevent DoS, injection hardening, and tenant isolation at the API layer. For the broader IDOR vulnerability class that GraphQL authorization failures belong to, see IDOR vulnerabilities in multi-tenant SaaS: how to find and fix them.
Why GraphQL Authorization Is Different from REST
REST APIs authorize at the route level: a middleware checks that the user can access /api/projects/:id before the handler runs. That middleware knows the HTTP method, the URL pattern, and the authenticated user — enough to make an access decision. GraphQL has one route (/graphql) and delegates all data retrieval to field resolvers. If you apply authorization only at the route level in a GraphQL application, you have granted access to the entire schema to any authenticated user. The resolver — not a middleware — is where authorization must happen.
- →Object-level authorization is OWASP API Security Top 10 #1 (Broken Object Level Authorization, BOLA). GraphQL's nested object model means every field resolver that fetches data is a potential BOLA entry point. A GraphQL schema with 40 types and 200 resolvers has 200 potential authorization bypass points. Each one must be tested individually.
- →In REST, GET /api/projects returns a list scoped to the requesting user. In GraphQL, a projects resolver must scope its database query to the requesting user's tenant — and so must every nested resolver that runs for fields within each project object. A projectOwner resolver that runs without tenant scoping has bypassed the parent resolver's check.
- →GraphQL mutations are route-agnostic. A mutation addTeamMember that checks whether the current user is an org admin before granting access must enforce that check inside the resolver — not in a middleware that only knows the HTTP method is POST and the content type is application/json.
Resolver-Level Authorization: The Patterns That Work at Scale
The correct authorization model for GraphQL is to enforce it inside every resolver that returns data or modifies state — not at a schema-level middleware and not in a single entry-point guard. The practical patterns that remain consistent across a codebase of hundreds of resolvers: an authorization helper called at the start of every data-fetching resolver, tenant context injected from the JWT at request entry and required in every database query, and explicit cross-tenant isolation tests. For the RBAC layer that sits on top of these patterns, see the guide on implementing RBAC in multi-tenant SaaS without leaking data.
- →Create an authorization helper called at the top of every data-fetching resolver: assertCanReadProject(context.user, projectId) or checkResourceAccess(context.user, 'read', resource). This helper throws an authorization error if the user lacks access. A consistent calling convention prevents the 'forgot to authorize this resolver' bug class.
- →Never pass a database entity from a parent resolver to a child resolver without re-authorizing in the child. GraphQL object resolution is tree-shaped: a viewer.organizations.projects.files chain runs four resolvers. Each must authorize against the requesting user independently. A parent-scoped result trusted by the child without its own check is a trust boundary violation.
- →Inject the tenant context from the JWT at request entry and require it in every database query. The tenant ID belongs in the GraphQL context object, not in query arguments. The correct WHERE clause is WHERE tenant_id = $tenantId AND id = $resourceId — never WHERE id = $resourceId alone, even when the parent resolver already filtered by tenant.
- →Consider an external authorization service (Cerbos, OpenFGA, Amazon Verified Permissions with Cedar) for complex permission models. These push policy logic out of resolver code and into a dedicated service, making permissions auditable, testable, and consistent across the full API surface instead of scattered across dozens of resolver files.
Introspection: When to Disable It and When to Lock It Down
GraphQL introspection is the built-in schema discovery mechanism: a __schema or __type query returns every type, field, mutation, and directive in the schema. It is essential for development tooling — GraphQL IDEs, type generators, and client code generators depend on it. It is also a roadmap for an attacker mapping your API before crafting targeted queries. The right configuration depends on who needs it and when.
- →Disable introspection in production for all external-facing API endpoints. The only runtime clients that legitimately need introspection are those generating type-safe clients at build time — those should run against the development schema in CI, not against the production endpoint. Disabling it adds meaningful friction to reconnaissance without affecting legitimate client operation.
- →Gate any in-app schema exploration with a privileged JWT scope rather than leaving introspection open to all authenticated users. Admin dashboard schema explorers, internal tooling, and operations interfaces are legitimate production users of introspection — scope them specifically rather than enabling introspection globally.
- →Understand that disabling introspection is not a substitute for resolver authorization. Field enumeration via error messages (sending type mismatches to discover accepted types) and schema extraction via tooling like Clairvoyance can reconstruct a large portion of a schema even with introspection disabled. Introspection controls reduce reconnaissance capability; authorization controls stop the actual exploits.
- →Audit type and field names for information leakage before they ship. A type named InternalComplianceScore, a field named adminOverrideEnabled, or a mutation named bypassBillingValidation tells an attacker where to probe even without introspection access. GraphQL schema design is a security decision.
Preventing Nested Query and Batch Query DoS Attacks
GraphQL's recursive query structure creates a resource exhaustion attack surface that REST APIs do not share. A single GraphQL request with deeply nested relationships can trigger O(n^k) database queries and exhaust server memory before the request completes. A batch of aliased mutations in a single request can execute hundreds of operations while appearing to the HTTP rate limiter as a single request. These are not theoretical risks — they appear regularly in production incident post-mortems for teams that did not build query complexity controls before launch.
- →Implement query depth limiting before you go to production. A maximum query depth of 5 to 8 levels covers all legitimate use cases in most SaaS products and prevents the deeply recursive queries (projects.users.organizations.projects.users...) that trigger unbounded resolver chains. Apollo Server, graphql-yoga, envelop, and graphql-ruby all have depth limiting plugins.
- →Implement query complexity analysis and enforce a per-request cost ceiling. Assign a cost to each field based on its data-fetching cost: a simple scalar field costs 1, a list field costs 10 plus the cost of each listed object. Reject requests whose total complexity exceeds your threshold before the query executes. This prevents the O(n^2) nested-list attack that bypasses depth limits alone.
- →Rate-limit mutations at the resolver level, not only at the HTTP layer. A single GraphQL request can include multiple mutations using aliases: mutate1: login(email:"a@b.com") mutate2: login(email:"c@d.com"). Each alias executes the resolver independently. HTTP-level rate limiting sees one request; your backend executes 50 mutations. Resolver-level rate limiting enforces limits where they actually need to apply.
- →Set a maximum query string length in bytes at the HTTP middleware layer. This is the cheapest protection against parse-time attacks: a query string of hundreds of kilobytes can consume significant parser time before the depth or complexity checks run. Combine it with standard content-length limits as part of your base HTTP middleware.
Injection Attacks in GraphQL Arguments
GraphQL arguments are user-controlled input passed to resolver logic. The injection risks are the same as in REST — SQL injection, NoSQL injection, SSRF, command injection — but the entry surface is different. In REST, injection input arrives in URL parameters and request bodies. In GraphQL, it arrives in query variables, inline argument literals, and directive arguments on any field in the query tree. Every argument on every field is a potential injection entry point.
- →Treat GraphQL variables as untrusted user input regardless of type. GraphQL's type system rejects arguments of the wrong type at parse time — this is free input validation. But type validation does not prevent SQL injection via a string argument that is passed directly into a query string. Parameterize all database queries built from GraphQL arguments, the same as you would for a REST handler.
- →Use specific scalar types (Int, ID, URL, Email) rather than String for arguments with constrained valid formats. A URL scalar type with a validation function rejects malformed URLs before the resolver runs, eliminating a class of SSRF entry points at the schema layer rather than in resolver code.
- →Watch for SSRF in resolvers that make HTTP requests using user-supplied URLs. A mutation that accepts a webhookUrl argument and fires an HTTP request to it is a classic SSRF entry point. Validate URLs against an explicit allowlist of safe domains before making the outbound request — do not rely on the client to supply safe URLs.
- →Watch for NoSQL injection in document-store backends. MongoDB $where and $regex operators can be injected via GraphQL arguments passed into query constructors without sanitization. If your resolver accepts an object argument and spreads it directly into a MongoDB query, an attacker can supply $where as a valid object field.
Cross-Tenant Data Isolation at the GraphQL Layer
Multi-tenant SaaS GraphQL APIs must enforce a constraint that single-tenant applications never need: every data-fetching resolver must scope its result to the requesting user's tenant. The cross-tenant IDOR — where an authenticated user queries another tenant's resources by guessing or enumerating resource IDs — is the most damaging GraphQL vulnerability in SaaS. Supabase Row Level Security provides a database-layer defense-in-depth for this pattern, covered in the Supabase RLS security guide. The GraphQL layer controls are the first line of defense.
- →Inject the tenant ID from the authenticated JWT into the GraphQL context at request entry. Add it to the context object so every resolver has access without accepting it as a query argument. This is the architectural pattern that prevents the 'forgot to scope this resolver' bug class — when the tenant ID lives in the context rather than the query, there is no path to another tenant's data without actively bypassing the context.
- →Never use a client-supplied tenant identifier for data scoping. A query argument tenantId that is used to filter a database query can be exploited by any authenticated user who supplies a different tenant's ID. The only trusted tenant context is the one extracted from the authenticated session token.
- →Build an explicit cross-tenant isolation test suite and run it in CI. A test that authenticates as User A in Tenant A, then queries resource IDs belonging to Tenant B, should return authorization errors — not Tenant B's data. Run these tests against every resolver that returns tenant-scoped data. This is the test class that automated security scanners miss.
- →Layer PostgreSQL Row Level Security as a defense-in-depth below the GraphQL layer. Even when a resolver forgets to include a tenant filter, a correctly configured RLS policy at the database layer will reject the cross-tenant query. Defense-in-depth here means that a single resolver-level oversight does not automatically result in a cross-tenant data exposure.
Automated Security Baseline Checks with DeployReady
Many security baseline issues — missing security headers, exposed secrets in environment variables, unauthenticated endpoints — can be caught before launch with automated tooling. DeployReady is Belsoft's open-source CLI that runs 30+ static and dynamic security checks (OWASP Top 10, exposed secrets, missing headers, auth bypass patterns) and returns a 0–100 readiness score. Run npx deployready against your application before launch to catch the automated baseline. The resolver-level authorization gaps, cross-tenant IDOR patterns, and query complexity vulnerabilities covered in this guide require manual testing that automated tools cannot replicate — that is where a security audit adds value beyond the automated baseline.
Frequently Asked Questions
What is the most common GraphQL security vulnerability?
Authorization bypass is the most common GraphQL security vulnerability, accounting for 87% of known GraphQL issues in 2026. The root cause is resolver-level authorization being missing or incomplete: teams familiar with REST APIs apply authorization at the route level and do not realize that GraphQL's single-endpoint model requires per-resolver authorization checks. The result is that any authenticated user can query any field in the schema that lacks an explicit resolver-level access check.
Should I disable GraphQL introspection in production?
Yes, for external-facing production APIs. Introspection returns a complete schema map — type names, field names, argument types, and mutation signatures — giving attackers a roadmap before they begin probing. Disable it in production, retain it in development and staging, and gate any in-app schema exploration (admin tooling, internal dashboards) with a privileged JWT scope rather than leaving it open to all authenticated users.
How do you prevent cross-tenant data leaks in a GraphQL SaaS API?
Inject the tenant ID from the authenticated JWT into the GraphQL execution context at request entry, and require every data-fetching resolver to include that tenant ID in its database query as a mandatory WHERE clause. Never accept a client-supplied tenant identifier for data scoping — the only trusted tenant context is the one extracted from the authenticated session. Enforce this with an explicit cross-tenant isolation test suite: authenticate as Tenant A, query Tenant B's resource IDs, and assert the result is an authorization error — never data.
How do you prevent GraphQL denial-of-service attacks?
Three controls in combination: query depth limiting (maximum 5–8 levels prevents recursive resolver exhaustion), query complexity analysis with a per-request cost ceiling (prevents O(n^2) nested-list attacks that bypass depth limits), and resolver-level rate limiting on mutations (prevents alias-based batching that makes one HTTP request execute 50 mutations). HTTP-level rate limiting alone is not sufficient because a single GraphQL request can contain arbitrarily complex operations.
Is a GraphQL API less secure than a REST API?
Neither is inherently less secure — the attack surfaces are different. REST APIs have a large URL surface with per-route authorization that is easy to audit by inspection. GraphQL APIs have one endpoint with per-resolver authorization that is easy to miss and harder to audit by inspection. GraphQL's recursive query model also creates DoS risks — depth attacks, complexity attacks, batching attacks — that REST does not have. Teams coming from REST often underestimate resolver authorization as a security requirement until they face an authorization bypass report from a customer or penetration tester.
How Belsoft Helps Secure SaaS GraphQL APIs
Belsoft's security audit service covers GraphQL APIs as part of every SaaS security review: resolver-level authorization testing against each field in the schema, cross-tenant isolation checks with explicit Tenant A vs Tenant B query tests, introspection configuration review, injection testing on mutation arguments and query variables, and query complexity analysis. The manual review catches what automated tools miss — specifically the authorization bypass patterns where a resolver trusts its parent's already-scoped result, or a mutation argument is passed into a database query without sanitization.
Run DeployReady first for the automated security baseline: npx deployready catches exposed secrets, missing security headers, and common auth bypass patterns in seconds. When you are ready to close the resolver-level authorization gaps that automation cannot find — especially before an enterprise security review or a penetration test — book a call with the team.
“In REST you protect routes. In GraphQL you protect resolvers — every single one of them. Miss one and you have an authorization bypass, not a minor gap.”
Written by
Marco Silva
Security Engineer, Belsoft Solutions
More from the blog
Ready to partner?
Let's talk about your company.
30 minutes. No pitch. We talk through how you run today and where AI and automation would help.
Book a free audit
