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.

12 Battle-Tested GraphQL Authorization Patterns + CI Gates

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

  1. Object ID guessing (user(id:"123")) and cross-tenant access
  2. 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 }
  3. Nested resolvers leaking related objects (order.user, file.owner)
  4. Introspection & tooling aiding recon in prod
  5. 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

Screenshot of the free tools webpage where you can access security assessment tools for different vulnerability detection.
Screenshot of the free tools webpage where you can access security assessment tools for different vulnerability detection.

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

An example of a vulnerability assessment report generated using our free tool provides valuable insights into potential vulnerabilities.
An example of a vulnerability assessment report generated using our free tool provides valuable insights into potential vulnerabilities.

CI policy gates: stop auth regressions before merge

Gate A — SDL linter (Node script)

  • Fails if type Query/Mutation fields 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)

  1. Require auth on /graphql (pattern #1)
  2. Turn on persisted queries and disable introspection
  3. Apply repo-level ownership joins (pattern #2/#5)
  4. Enforce tenant in JWT and in DB predicates
  5. 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)


Further reading on our sites


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.

Free Consultation

If you have any questions or need expert assistance, feel free to schedule a Free consultation with one of our security engineers>>

🔐 Frequently Asked Questions (FAQs)

Find answers to commonly asked questions about GraphQL Authorization Patterns + CI Gates.

Get a Quote

Leave a Comment

Your email address will not be published. Required fields are marked *

Cyber Rely Logo cyber security
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.