How do you secure GraphQL with field-level auth at scale?
GraphQL Developer
answer
I load identity (JWT/OIDC) into context and precompute a permission map (roles, attributes, tenant, scopes). Field-level rules live in the schema via directives (e.g., @auth(require:["order:read"])) and custom validation prunes disallowed selections before execution. Row-level checks run in resolvers or the DB (RLS). Performance comes from memoized guards, DataLoader batching, cost/depth limits, and persisted ops. Subscriptions re-auth on connect/refresh tokens on re-subscribe.
Long Answer
A secure, fast GraphQL service treats identity as data, authorization as schema, and performance as a first-class constraint. My approach layers authentication (AuthN), authorization (AuthZ), and query governance so field guards are enforced early, rows are filtered close to data, and resolvers stay hot-path efficient.
1) Authentication: identity into context
I accept JWT/OIDC from a gateway or header/cookie, verify signature/expiry and audience, then derive a compact auth context:
- subject (sub), tenant/org, roles/scopes, feature flags
- attribute bag for ABAC (region, department, ownership ids)
- correlation ids for observability
No PII is stored in logs; the raw token is never passed to resolvers. For subscriptions, I re-verify on connectionInit and when tokens rotate (connection params or connection middleware).
2) Schema-first authorization with directives
Auth must be declarative. I encode rules in the schema via directives:
type Order @auth(require: ["order:read"]) {
id: ID!
total: Money @auth(require: ["order:read:total"])
customerEmail: String @auth(require: ["order:read:pii"], mask: true)
}
A compile-time directive transformer generates guard metadata (required scopes, attribute predicates) and optional masking behavior for sensitive fields. This keeps policy close to the contract and versioned with the schema.
3) Early rejection at validation time
Before execution, a custom validation rule traverses the query AST with the user’s permission map and fails or prunes disallowed fields and fragments. Benefits:
- Unauthorized selections never reach resolvers.
- Errors aggregate per path with consistent codes.
- Complexity is calculated post-prune, improving accuracy.
For partial access, the rule rewrites selections to masked placeholders (e.g., null, "[redacted]", or filtered lists) when policy allows redaction instead of denial.
4) Row-level authorization (RLS/ABAC)
Field access is necessary but not sufficient. For row-level security, I prefer pushing checks to data sources:
- SQL: use Row Level Security (e.g., Postgres RLS) with session variables (SET app.user_id, app.tenant_id) so the DB enforces WHERE tenant_id = current_setting('app.tenant_id').
- NoSQL: inject server-side predicates (ownership, tenant) into queries; never trust client filters.
- Services: pass scoped tokens to downstreams (audience/claims restricted).
Resolvers assemble predicates from the attribute bag and always combine client filters with server filters (AND), never replace them.
5) Resolver performance patterns
To avoid auth becoming the bottleneck:
- Memoize guard checks per (field, type, role set) for a request; they’re pure lookups.
- Use DataLoader (or equivalent) to batch N:1 fetches and deduplicate by key and tenant.
- Prefer projection-aware fetchers: build selected columns from GraphQLResolveInfo and remove masked fields.
- Guard once per path root (e.g., Order) and inherit for child fields unless a stricter rule applies.
- Emit fast denies: if the root is forbidden, short-circuit subtree execution.
6) Query governance (cost, depth, persistence)
Prevent pathological queries irrespective of auth:
- Cost/complexity: assign weights per field (e.g., list fields cost size * child cost); cap total cost by plan/role.
- Depth/alias limits to stop recursion/alias abuse.
- Persisted operations for public clients: only whitelisted hashes can run, binding auth to operation id.
- Rate limits keyed by tenant and subject; stricter for anonymous.
7) Federation and gateways
In federated graphs (Apollo/GraphOS, GraphQL Mesh), I push edge authorization to the gateway where possible (auth directives compiled into router plugins) and propagate a scoped identity to subgraphs via headers or JWKS-verified tokens. Subgraphs still enforce RLS—trust but verify. For stitching, each service validates independently; shared directive semantics keep behavior consistent.
8) Subscriptions and real-time
For websockets/SSE:
- Re-check authorization on subscribe and on payload emission if the identity changes (revocation/list changes).
- Use channel partitioning by tenant/subject to avoid broadcasting confidential events.
- Keep payloads minimal and mask fields at publish time.
9) Auditing, errors, and DX
Return predictable error codes (FORBIDDEN, UNAUTHENTICATED), never leak policy internals. Emit structured audit events (who read what, aggregates only) with sampling and hashing to respect privacy. Developers get a policy linter that flags missing directives on risky fields and a storybook-style catalog that previews masking and denial states.
10) Testing and rollout
- Contract tests: snapshot queries with different roles/tenants verifying pruned selections and masked outputs.
- Policy drift checks: CI ensures every type/field under certain namespaces carries explicit policy or inherits a default.
- Shadow mode rollout: log would-deny vs actual result to tune rules before enforcing.
This blueprint keeps field-level security declarative and fast (reject early, memoize checks), row-level defenses close to data, and queries governed, so GraphQL stays flexible without trading off safety or performance.
Table
Common Mistakes
- Doing auth only in resolvers, letting unauthorized selections traverse the executor and waste time.
- Hiding policy in app code rather than the schema, causing drift and surprises.
- Trusting client filters for tenancy/ownership; missing row-level enforcement in DB/queries.
- Fetching all columns then masking in app—expensive and leaky.
- Skipping memoization so the same guard runs thousands of times per request.
- No cost/depth controls; complex queries DOS the service even if authorized.
- Mixing user/tenant state into the cache key incorrectly, creating data leaks.
- Subscriptions authenticated once, never re-checked on token rotation.
- Over-granular directives on every field with copy-pasted strings; hard to maintain.
- Verbose auth errors that reveal internal policy or existence of protected records.
Sample Answers
Junior:
“I verify JWTs and put roles in context. I use directives to mark protected fields and check them in resolvers. DataLoader batches DB calls, and I add depth limits so big queries can’t overload the API.”
Mid:
“I generate guard metadata from @auth directives and run a validation rule that prunes unauthorized selections before execution. Row security is enforced with tenant filters (or DB RLS). I memoize guard checks and build DB projections from the selection set. Persisted operations and cost limits protect performance.”
Senior:
“Auth is schema-driven and compiled: @auth becomes fast guard tables. A custom validator rejects or masks fields pre-execution; resolvers push ABAC predicates to the DB (RLS). The per-request permission map is memoized; DataLoader + projection cut IO. Federation propagates scoped identity; subgraphs still enforce RLS. Subscriptions re-auth on connect and emit. We track deny/mask metrics, cost, and policy drift in CI.”
Evaluation Criteria
- AuthN: Robust JWT/OIDC verification; stable identity in context with roles/attributes.
- Schema Policy: Field/type rules expressed as directives or SDL annotations; maintainable and versioned.
- Early Enforcement: Validator that prunes/blocks unauthorized selections pre-execution.
- Row Security: Tenant/ownership enforced at DB or via server predicates; never client-only.
- Performance: Memoized guards, DataLoader, projection-aware fetch; minimal masking work.
- Governance: Cost/depth/alias limits; persisted ops for public clients.
- Federation/Realtime: Consistent auth across subgraphs; re-auth for subscriptions.
- Observability/DX: Deny/mask metrics, audits, clear error codes; CI linting for policy coverage.
Red flags: Resolver-only checks, no cost limits, fetching everything then masking, trusting client filters, single-shot subscription auth.
Preparation Tips
- Build a demo schema with @auth(require:[…]) and generate guard tables; write a validation rule that prunes unauthorized fields.
- Implement a per-request permission map from JWT (roles + attributes). Memoize (type, field, roleSet) checks.
- Add projection builders from GraphQLResolveInfo so queries fetch only needed columns.
- Enforce RLS in Postgres or server-side predicates in NoSQL; test cross-tenant leaks.
- Wire DataLoader and confirm 1 round-trip per entity type.
- Add cost/depth analysis and persisted ops; break the build if limits aren’t enforced.
- For subscriptions, re-auth on connectionInit and rotation; test token expiry mid-stream.
- Create CI snapshots for allowed/denied/masked responses; add a linter for missing directives in sensitive types.
- Instrument metrics: deny counts, masks, avg cost, and p95 latency by role.
Real-world Context
B2B analytics: Moving auth to schema directives + pre-execution pruning cut P95 latency by 18% and eliminated “deny at resolver” wasted work. A deny/mask dashboard revealed over-strict rules on customer emails; a targeted relax improved support workflows without leaks.
Marketplace: Tenancy leaks vanished after pushing filters into Postgres RLS; a red-team verified cross-tenant queries failed even with crafted inputs.
Public API: Persisted queries + cost limits stopped abusive deep queries; traffic spikes no longer harmed tail latency.
Federation: A gateway plugin enforced directives at the edge while subgraphs applied RLS; inconsistent policies across teams disappeared after adopting shared directive semantics and CI linting.
Realtime: Subscription tokens now re-validate on reconnect; masked payloads lowered incident risk when roles changed mid-session.
Key Takeaways
- Put policy in the schema; compile directives to fast guards.
- Reject or prune unauthorized selections before execution.
- Enforce row-level rules near data (RLS/predicates).
- Keep it fast with memoized checks, DataLoader, and projection-aware resolvers.
- Govern queries (cost, depth, persisted ops) and re-auth subscriptions.
Practice Exercise
Scenario:
You’re securing a multi-tenant GraphQL API (User, Order, Invoice). Requirements: field-level rules (emails masked unless support role), tenant isolation, efficient lists, cost limits, and secure subscriptions for order status.
Tasks:
- Schema policy: Add @auth to types/fields, e.g., User.email requires user:read:pii with mask:"••••@redacted". Order.total requires order:read. Generate guard tables at build time.
- AuthN & context: Verify JWT/OIDC; derive {sub, tenantId, roles, attrs}. Build a permission map keyed by role set.
- Validator: Implement a pre-execution rule that prunes forbidden selections and masks permitted-but-sensitive fields. Fail the op if a root field is forbidden.
- Row security: In Postgres, enable RLS using current_setting('app.tenant_id'). In resolvers, set app.tenant_id from context and add ownership predicates for user-scoped reads.
- Performance: Add DataLoader for User/Order lookups and a projection builder from info to select only required columns; memoize guard checks per request.
- Governance: Enforce depth≤6 and a cost budget; implement persisted operations for the public app.
- Subscriptions: On orderStatusChanged, verify auth on subscribe and emit; mask customerEmail unless role permits; re-validate on token rotation.
- CI & observability: Snapshot tests for admin/support/user roles (allowed/denied/masked). Export metrics: auth_denies_total{type,field}, masked_fields_total, and average query cost.
Deliverable:
A repo with schema, directive transformer, validator, resolvers using RLS/predicates, loaders/projections, cost limits, subscription guards, tests, and metrics—demonstrating field-safe, fast GraphQL authorization.

