12 Battle-Tested GraphQL Authorization Patterns + CI Gates
Broken Object Level Authorization (BOLA/IDOR) is still the #1 GraphQL abuse path. This guide shows practical, resolver-level GraphQL authorization patterns—plus ready-to-paste tests and CI policy gates—so you can stop object-level data leaks without stalling delivery.

If you’re defining authorization right now, don’t miss our guide: OPA vs Cedar: Ship Policy-as-Code in 7 Steps.
TL;DR: What you’ll implement
- 12 fix patterns for GraphQL Authorization across schema, resolvers, and platform
- Drop-in Node (Apollo v4) and Java (graphql-java) middleware for BOLA/IDOR
- Unit/integration tests that prove authorization holds
- CI policy gates to block unguarded fields pre-merge
- Runbooks for hotfix vs. long-term refactor
Threats first: how GraphQL BOLA/IDOR actually happens
- Object ID guessing (
user(id:"123")) and cross-tenant access - Alias/batch abuse to smuggle unauthorized IDs in one request:
query { me: user(id: "123") { id email } victim: user(id: "124") { id email } # unauthorized via alias } - Nested resolvers leaking related objects (
order.user,file.owner) - Introspection & tooling aiding recon in prod
- Deep/expensive queries that bypass guardrails or DoS your checks
The 12 Fix Patterns (with real code)
1) Deny-by-default on the GraphQL endpoint
Always require an authenticated principal before parsing/validating.
Express/Apollo v4
app.use("/graphql", (req, res, next) => {
if (!req.user) return res.status(401).end();
next();
});
Spring Boot
@Bean
public WebMvcConfigurer authGate() {
return new WebMvcConfigurer() {
@Override public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) throws Exception {
if (req.getAttribute("principal") == null) { res.setStatus(401); return false; }
return true;
}
});
}
};
}
2) Ownership guard in every object fetch (BOLA killer)
Never fetch by id alone—bind tenant + subject into the predicate.
Node: repository + resolver
// repo.ts
export async function findUserByIdForPrincipal(db, id, principal) {
return db.user.findFirst({
where: { id, tenantId: principal.tenantId }
});
}
// resolvers/user.ts
const Query = {
user: async (_: any, { id }, ctx) => {
const u = await findUserByIdForPrincipal(ctx.db, id, ctx.principal);
if (!u || u.id !== ctx.principal.sub) ctx.throwForbidden(); // or ABAC check
return u;
},
};
Java (graphql-java) DataFetcher wrapper
DataFetcher<?> guarded(DataFetcher<?> inner, Predicate<AuthzCtx> policy) {
return env -> {
AuthzCtx a = AuthzCtx.from(env);
if (!policy.test(a)) throw new AccessDeniedException("forbidden");
return inner.get(env);
};
}
// bind per-field
codeRegistry.dataFetcher(
FieldCoordinates.coordinates("Query","user"),
guarded(userByIdFetcher, a -> a.tenantMatches() && a.subjectOwns(env.getArgument("id")))
);
Free Website Vulnerability Scanner Landing Page

3) Attribute-Based Access Control (ABAC) helper
Centralize decisions; pass resource + action + attributes.
// abac.ts
type Decision = "ALLOW"|"DENY";
export function decide(principal, resource, action, attrs): Decision {
if (principal.role === "ADMIN") return "ALLOW";
if (resource === "User" && action === "READ") {
return (attrs.userId === principal.sub && attrs.tenantId === principal.tenantId) ? "ALLOW" : "DENY";
}
return "DENY";
}
Usage in resolvers:
if (decide(ctx.principal, "Order", "UPDATE", { tenantId: order.tenantId, ownerId: order.userId }) !== "ALLOW")
ctx.throwForbidden();
4) Field-level auth via schema directives
Keep rules close to the SDL and enforce in middleware.
SDL
directive @auth(resource: String!, action: String!) on FIELD_DEFINITION | OBJECT
type User @auth(resource:"User", action:"READ") {
id: ID!
email: String! @auth(resource:"User", action:"READ_SENSITIVE")
}
Apollo v4 directive transformer
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
export const authDirective = (schema) => mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const dir = getDirective(schema, fieldConfig, "auth")?.[0];
if (!dir) return fieldConfig;
const { resource, action } = dir;
const prev = fieldConfig.resolve!;
fieldConfig.resolve = async (src, args, ctx, info) => {
const attrs = { tenantId: ctx.principal.tenantId, ownerId: src.userId ?? src.id };
if (decide(ctx.principal, resource, action, attrs) !== "ALLOW") throw new Error("FORBIDDEN");
return prev(src, args, ctx, info);
};
return fieldConfig;
}
});
5) Resolver-level ownership join helpers
Codify secure lookups so developers can’t “just query by id.”
export function byIdOwned<T>(table: string, id: string, p: Principal) {
return db[table].findFirst({ where: { id, tenantId: p.tenantId, ownerId: p.sub }});
}
6) Tenant boundary in JWT and DB
Standardize tenantId and enforce it in every query; never infer from hostnames alone.
7) Persisted queries (allow-list) in production
Only accept hashes of pre-approved operations.
// server init
const allowlist = new Map<string,string>([
// sha256 -> query string
["8c4...f1", "query Me { me { id email } }"]
]);
app.post("/graphql", (req, res, next) => {
const { extensions } = req.body;
const hash = extensions?.persistedQuery?.sha256Hash;
if (!hash || !allowlist.has(hash)) return res.status(400).json({ error: "Unknown query" });
req.body.query = allowlist.get(hash);
next();
});
8) Query depth & cost limits
Stop “hydra” queries that bypass business checks.
Node (graphql-query-complexity)
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
const MAX_COMPLEXITY = 1000, MAX_DEPTH = 10;
const server = new ApolloServer({ /* ... */ , plugins:[{
requestDidStart: () => ({
didResolveOperation(ctx) {
if (ctx.operation?.depth! > MAX_DEPTH) throw Error("Too deep");
const complexity = getComplexity({
schema: ctx.schema, operationName: ctx.operationName,
query: ctx.request.document, variables: ctx.request.variables,
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })]
});
if (complexity > MAX_COMPLEXITY) throw Error("Too complex");
}
})
}]});
Java (graphql-java)
GraphQL.newGraphQL(schema)
.instrumentation(new ChainedInstrumentation(
new MaxQueryDepthInstrumentation(10),
new MaxQueryComplexityInstrumentation(1000)
))
.build();
9) Disable introspection in prod (or gate it)
const allowIntrospection = process.env.NODE_ENV !== "production" && ctx.principal?.role === "ADMIN";
10) N+1 safe loaders with auth-aware keys
Scope DataLoader caches per principal to avoid cross-user leakage.
const userLoader = new DataLoader(async (ids: string[]) => {
return db.user.findMany({ where: { id: { in: ids }, tenantId: ctx.principal.tenantId }});
}, { cacheKeyFn: id => `${ctx.principal.tenantId}:${id}` });
11) Reject mixed-principal aliasing
Detect multi-ID access in a single operation and short-circuit.
function validateArgsTree(info: GraphQLResolveInfo, principal: Principal) {
const ids = collectIds(info.fieldNodes);
if (ids.some(id => !isOwnedByPrincipal(id, principal))) throw new Error("FORBIDDEN_ALIASING");
}
Call at the top of sensitive resolvers.
12) Soft-delete & draft awareness
Include isDeleted=false and status IN (OWNED, PUBLISHED) in every resolver to avoid stale/leaked records.
Ready-to-paste tests
Node: Jest unit test for ownership
it("denies reading other users' profile", async () => {
const ctx = fakeCtx({ sub: "u-1", tenantId: "t-1" });
await expect(Query.user(null, { id: "u-2" }, ctx)).rejects.toThrow(/forbidden/i);
});
Node: Supertest integration (persisted queries on)
await request(app)
.post("/graphql")
.send({ extensions:{ persistedQuery:{ sha256Hash:"8c4...f1" }}, variables:{} })
.set("Authorization", `Bearer ${tokenOf("u-1","t-1")}`)
.expect(200);
Java: JUnit + graphql-java
@Test
void cannotReadOtherTenantUser() {
ExecutionInput in = ExecutionInput.newExecutionInput()
.query("{ user(id:\"u-2\"){ id } }")
.context(ctxOf("u-1","t-1"))
.build();
assertThat(graphQL.execute(in).getErrors()).isNotEmpty();
}
Sample Report by our tool to check Website Vulnerability

CI policy gates: stop auth regressions before merge
Gate A — SDL linter (Node script)
- Fails if
type Query/Mutationfields lack@auth - Fails if object types with PII (e.g.,
User,Invoice) miss@auth - Fails on
@deprecated(reason:"auth")bypass attempts
// scripts/check-auth-directives.ts
import { parse, visit } from "graphql";
import fs from "node:fs";
const sdl = fs.readFileSync("schema.graphql","utf8");
const doc = parse(sdl); const errors:string[] = [];
visit(doc, {
ObjectTypeDefinition(node) {
const needsAuth = ["User","Order","Invoice"].includes(node.name.value);
const hasAuth = (node.directives||[]).some(d => d.name.value==="auth");
if (needsAuth && !hasAuth) errors.push(`Type ${node.name.value} missing @auth`);
if (["Query","Mutation"].includes(node.name.value)) {
for (const f of node.fields||[]) {
const fa = (f.directives||[]).some(d=>d.name.value==="auth");
if (!fa) errors.push(`Field ${node.name.value}.${f.name.value} missing @auth`);
}
}
}
});
if (errors.length) { console.error(errors.join("\n")); process.exit(1); }
GitHub Actions
name: auth-gates
on: [pull_request]
jobs:
check-auth:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: node scripts/check-auth-directives.ts
Gate B — Dynamic replay
- Replays a denylist of dangerous queries (multi-alias access, deep nesting) against your preview build; build fails if any return non-errors.
Runbooks
Hotfix (today)
- Require auth on
/graphql(pattern #1) - Turn on persisted queries and disable introspection
- Apply repo-level ownership joins (pattern #2/#5)
- Enforce tenant in JWT and in DB predicates
- Add query depth/cost caps (pattern #8)
Long-term (this sprint → next)
- Migrate to directive-driven field auth (#4)
- Normalize IDs and ownership across services
- Expand ABAC policies for edge cases
- Add CI gates and replay tests
- Periodic GraphQL pentest during each release train
Developer recipes (copy/paste)
Apollo v4 server skeleton with all guards
import { ApolloServer } from "@apollo/server";
import { authDirective } from "./directives/auth";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { applyMiddleware } from "graphql-middleware";
const typeDefs = /* GraphQL */`
directive @auth(resource:String!, action:String!) on FIELD_DEFINITION | OBJECT
type Query { me: User @auth(resource:"User", action:"READ") }
type User { id:ID!, email:String! @auth(resource:"User", action:"READ_SENSITIVE") }
`;
const resolvers = { Query: { me: (_,_a,ctx)=> ctx.loaders.user.load(ctx.principal.sub) } };
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirective(schema);
export const server = new ApolloServer({ schema });
graphql-java with instrumentation & guarded fetchers
GraphQLSchema schema = // build SDL with @auth equivalent via directives or metadata
GraphQL graphQL = GraphQL.newGraphQL(schema)
.instrumentation(new ChainedInstrumentation(
new MaxQueryDepthInstrumentation(10),
new MaxQueryComplexityInstrumentation(1000),
new TracingInstrumentation()))
.build();
Related services (get expert help)
- Risk Assessment & Threat-Model Review — fast triage of GraphQL auth risks and BOLA paths.
👉 pentesttesting.com/risk-assessment-services - Hands-on Remediation — pair with your team to wire ABAC, directives, and CI gates across services.
👉 pentesttesting.com/remediation-services
Further reading on our sites
- 7 Proven Defenses for the Pixnapping Android Exploit — GPU side-channel containment tips (helpful for mobile GraphQL consumers).
👉 https://www.cybersrely.com/pixnapping-android-exploit/. - npm supply chain attack 2025: ‘Shai-Hulud’ CI fixes — CI hardening patterns you can reuse for GraphQL build gates.
👉 cybersrely.com/npm-supply-chain-attack-2025/ - Prevent Sensitive Data Exposure in React.js — 7 Best Ways — complements GraphQL Authorization by fixing client-side leaks.
👉 cybersrely.com/prevent-sensitive-data-exposure-in-react-js/ - Best 7 Tips for SQLi Prevention in React.js (with Examples) — relevant when your GraphQL resolvers hit SQL backends.
👉 cybersrely.com/sqli-prevention-in-react-js/
CTA: Secure your GraphQL Authorization today
- Need a quick GraphQL Authorization review? Book a consult via Cyber Rely.
- Prefer to self-check first? Run a free scan at free.pentesttesting.com to surface header/CORS/OpenAPI issues that enable BOLA/IDOR.
🔐 Frequently Asked Questions (FAQs)
Find answers to commonly asked questions about GraphQL Authorization Patterns + CI Gates.