Security (7 Questions)
Duration: ~20 minutes | Difficulty: Intermediate | Prerequisites: Performance and DataLoader (7 Questions)
GraphQL security is not a subset of REST API security. The questions in this chapter test whether you understand why the client-driven data model creates attack vectors that REST's fixed-endpoint model does not, and whether you know the layered defenses that address them. Candidates who have only read the OWASP Top 10 will struggle here. Candidates who have built their own security stack, or worked through a systematic treatment of GraphQL's attack surface, will find these questions natural.
Question 1: Why is GraphQL security different from REST API security?
Short answer: In REST each endpoint has a fixed shape and scope, so security decisions happen per route at the HTTP layer. GraphQL has one endpoint, and the client describes the query shape. Authorization decisions therefore move into the resolver layer, resource-consumption attacks become client-controllable, and introspection makes schema reconnaissance easy.
Why this is right / what interviewers want to hear:
This is the foundational question of GraphQL security. The answer reveals whether you have a genuine mental model of the security posture difference, not just a list of GraphQL-specific attack names.
The REST security model relies on fixed routes. A GET /movies endpoint returns a fixed-cost response with a fixed shape. A WAF can inspect the URL. Rate limiting can apply per route. Authentication can be applied per route group with Spring Security's HttpSecurity.authorizeRequests(). The shape of the response is known at deploy time.
GraphQL inverts this. The single /graphql endpoint receives a query body that the server interprets at runtime. The shape of the response is determined by the query, which the attacker writes. A WAF inspecting POST /graphql cannot distinguish between a benign { movies { id title } } and a malicious { movies { cast { movies { cast { movies { ... } } } } } }. They are both valid HTTP requests to the same endpoint. The WAF cannot tell them apart.
This inversion has three specific consequences:
- Authorization moves into the resolvers, because the HTTP layer cannot express that granularity. An
@PreAuthorize("hasRole('ADMIN')")annotation oncreateMovieworks correctly; a catch-allhttpSecurity.requiresSecure(...)on/graphqlcovers transport security and says nothing about field-level access. - Resource consumption becomes a client decision. In REST, the server knows at query-construction time how expensive a response will be. In GraphQL the cost follows the client's query shape, so without depth and complexity limits the client can construct arbitrarily expensive queries.
- The schema documents itself through introspection, which makes reconnaissance faster and more thorough than it is against a REST API.
Question 2: What is a query depth attack and how do you defend against it?
Short answer: A query depth attack is a query that nests field selections to an extreme depth, exploiting a self-referential or deeply recursive relationship to force exponential resolver fan-out. Defend against it with a depth limit that rejects queries whose selection tree exceeds a configured maximum depth, applied during validation, before any resolver runs.
Why this is right / what interviewers want to hear:
The classic depth attack exploits a self-referential relationship in the schema. If Movie has a relatedMovies: [Movie!]! field, a malicious client can write:
{
movies {
relatedMovies {
relatedMovies {
relatedMovies {
relatedMovies {
relatedMovies {
id title
}
}
}
}
}
}
}
If movies returns 10 movies and each movie has 10 related movies, this query asks the server to resolve 10^5 movie objects at depth 5. The database is hit once per node: 10 + 100 + 1,000 + 10,000 + 100,000 = 111,110 queries. The client sends one HTTP request.
The depth limit defense works by counting the maximum nesting depth of the query's selection set during the validation phase, before any resolver runs. If the query exceeds the configured limit (a common production value is 10-15 for most schemas), the server returns a validation error and never executes any resolvers. This is cheap to enforce: the AST traversal is constant-time relative to the schema size and proportional to the query document size.
Two practical notes for strong answers: (1) the depth limit must be schema-aware for accurate measurement. The naive implementation counts syntactic nesting depth; a smarter implementation understands that inline fragments do not add semantic depth. (2) the depth limit alone does not protect against all resource-exhaustion attacks, it protects against depth, not breadth. A query that is only 3 levels deep but requests 100 fields at each level is still expensive, and a complexity limit is needed to address that.
In Spring, depth limiting is implemented via a MaxQueryDepthInstrumentation bean. In Quarkus with SmallRye GraphQL, it is quarkus.smallrye-graphql.instrumentation-query-depth, which defaults to 10, alongside quarkus.smallrye-graphql.instrumentation-query-complexity for the complexity budget.
Question 3: What is an alias amplification attack?
Short answer: An alias amplification attack uses GraphQL aliases to repeat the same expensive query multiple times within a single operation, making the server execute the field N times while the HTTP request is counted only once. Rate limiting by HTTP request does not prevent it.
Why this is right / what interviewers want to hear:
GraphQL aliases are a legitimate feature that allow clients to request the same field multiple times with different arguments in a single query. { cheapMovie: movie(id: "1") { title } expensiveMovie: movie(id: "2") { title } } is valid and useful. An alias attack exploits this by repeating an expensive field many times:
{
m1: movies(genre: "action") { cast { movies { id } } }
m2: movies(genre: "action") { cast { movies { id } } }
m3: movies(genre: "action") { cast { movies { id } } }
# ... repeated 100 times
}
This is one HTTP request. An IP-level rate limiter that allows 100 requests per minute per IP allows 100 × 100 = 10,000 expensive list queries in one minute, because each HTTP request carries 100 aliases.
The defense is an alias count limit: reject queries that have more than N aliases for the same field. This is separate from depth limiting and complexity limiting, neither of those catches the alias case specifically, because the query may be shallow and each individual alias invocation may be within the complexity budget.
Alias count limiting is less commonly implemented than depth limiting, which is why this question distinguishes candidates who have specifically studied the attack surface from candidates who know only the canonical defenses. Alias attacks are category three in the taxonomy Question 7 sets out below.
Question 4: What is operation safelisting (persisted query enforcement), and what does it prevent?
Short answer: Operation safelisting is the practice of maintaining a list of pre-approved query documents (identified by hash) and rejecting any query that does not appear on the list. It prevents all classes of GraphQL-specific attacks that rely on the attacker crafting a query, because the attacker cannot send arbitrary queries, only the queries the operator has approved in advance.
Why this is right / what interviewers want to hear:
Operation safelisting is sometimes called "persisted query enforcement" to distinguish it from the weaker "automatic persisted queries" (APQ) feature, which is primarily about performance. APQ allows any query to be registered dynamically by any client. Safelisting allows only queries that were registered by the operator, typically at CI/CD time, when the client's query documents were compiled.
The attack classes that safelisting eliminates:
- depth attacks: the attacker cannot craft a deeply nested query if arbitrary queries are rejected
- alias amplification: the attacker cannot write a query with 100 aliases
- introspection: the attacker cannot send the
__schemaquery if it is not on the safelist - any other attack that requires writing a custom query The defenses for these attacks (depth limits, alias limits, introspection disabling) become belt-and-suspenders reinforcements instead of primary defenses.
What safelisting does not prevent: attacks against the approved queries themselves. If a safelisted query has a field with a resolver that is insufficiently authorized, the attacker can still exploit that authorization gap by sending the safelisted query. Safelisting removes the attacker's ability to enumerate the schema and craft novel attacks; it does not fix flaws in the approved operations.
Implementation: at build time, the client's GraphQL operations are hashed and the hashes are registered with the server (or with a persisted query server like Apollo). At runtime, the GraphQL HTTP handler checks the incoming operation hash against the registered set. If the hash is not found, the request is rejected with a 400 response before parsing or execution begins.
Question 5: How do you implement field-level authorization in GraphQL beyond @PreAuthorize?
Short answer: @PreAuthorize works for top-level resolvers, and fails for fields reached by traversal from public types, because the resolver of a private field may not see the request's authorization context. Three stronger options exist:
- check authorization inside the resolver, with explicit access to the security context;
- use an authorization directive backed by custom schema instrumentation;
- strongest of all, keep private fields off the types that public queries can reach, which is a schema design decision.
Why this is right / what interviewers want to hear:
This question is the crux of GraphQL authorization, and it is where most candidates' knowledge ends at @PreAuthorize, the Spring annotation that is sufficient for REST and for top-level GraphQL resolvers but insufficient for fields accessed via traversal.
The traversal problem: suppose your schema has User type with a socialSecurityNumber: String field that is annotated with @PreAuthorize("hasRole('ADMIN')") on its resolver. A query that directly asks for { user(id: "123") { socialSecurityNumber } } is blocked because the User.socialSecurityNumber resolver checks the principal. But a query like { movies { cast { socialSecurityNumber } } } where Movie.cast returns a list of User objects may bypass the check if the resolution path from Movie.cast to User does not re-evaluate the security context for each field. The @PreAuthorize on User.socialSecurityNumber should catch this, but the problem is that the GraphQL resolver tree does not reset the security context between parent and child resolvers. The child resolver inherits the context from the parent, and if the parent is a public field, the child may incorrectly "inherit" permission.
More concretely: the Movie.cast resolver runs without a security check because it is public. It returns a list of User objects. Then, for each User, the User.socialSecurityNumber resolver is called. If @PreAuthorize is correctly placed on the User.socialSecurityNumber resolver, and if Spring Security correctly evaluates the principal at that point, then the authorization check works. The failure mode is not in @PreAuthorize itself; it is in developers who add @PreAuthorize on the top-level query (user(id:)) but forget to add it on the field resolver accessed by traversal.
The defense strategies, in increasing strength:
-
Defense in depth with field-level
@PreAuthorize: ensure every sensitive field has an authorization check on its resolver, regardless of how it is accessed. This is the minimum. -
Row-level authorization in the service layer: instead of checking authorization in the resolver, check it in the service method that the resolver delegates to.
movieService.findCastForMovie(movieId, currentUser)filters out users that the current principal should not see. This is more robust because the authorization logic lives in one place regardless of how the data is accessed. -
Schema design: the strongest defense is to remove the sensitive field from the type that can be traversed publicly. A
Usertype in theMovie.castcontext should be aPublicPersontype with only public fields (id,name,bio). A separateUsertype with sensitive fields is only accessible via protected top-level queries. This eliminates the traversal attack path by construction.
Question 6: What should you log per GraphQL request, and what should you never log?
Short answer: Log the operation name, the operation type, the resolver duration distribution, an error summary of codes without messages, the user ID when the caller is authenticated, and the query hash for persisted queries. Never log:
- query variables carrying user-entered data, passwords and personal information among them;
- full query text in a high-volume production environment;
- response bodies.
Why this is right / what interviewers want to hear:
This is an operational security question. The interviewer is checking whether you have thought about what gets stored in logs and who can read those logs.
What to log: the operation name is the first thing to log because it identifies the operation in dashboards and alerts without requiring the full query text. "createMovie", "moviesByGenre" are safe identifiers. The operation type (query, mutation, subscription) is useful for routing different error rates to different alerting rules. Resolver duration distribution (p50, p99 for each resolver type) is essential for performance monitoring. Log error codes without error messages. Record that an authorization error occurred, and leave out what the user was trying to access. User ID (or an anonymized session identifier) is essential for incident investigation: "this query pattern came from this user" enables investigation without requiring the full query body. Query hash for persisted queries: safe to log because the hash is an opaque identifier, not the query content.
What not to log: query variables are the primary concern. A login(username: "alice", password: "hunter2") mutation has a password variable that you must never log. Even a searchMovies(query: "...") variable may contain personal information if the user is searching for their own name or a health condition. The safe rule is: never log raw variable values. If you need variable data for debugging, log a sanitized version with sensitive fields replaced by their types.
Full query text in high-volume environments: query text can be large (hundreds of bytes), and logging it for every request adds significant storage costs. More importantly, query text may inadvertently contain sensitive data if the client is incorrectly embedding sensitive values in the query body instead of in variables (a client-side bug that does happen). Log the query hash and keep the full text only for persisted queries, where the text is known in advance and known to be safe.
Response payloads: never log full response bodies in production. They contain user data. They are large. If you need to diagnose a specific response, use distributed tracing with sampling to capture occasional full request/response pairs, with appropriate access controls on the trace store.
Question 7: What are the five categories of GraphQL-specific attack?
Short answer: The five categories are:
- Information disclosure, through introspection and through error messages.
- Denial of service through query shape, meaning depth and complexity attacks.
- Batching and alias amplification, aimed at rate limiting.
- Field-level authorization bypass, reached by traversal from a public type.
- Operational and observability gaps, which let the other four run unnoticed.
Why this is right / what interviewers want to hear:
Knowing the taxonomy, and not just the individual attacks, demonstrates that you have studied the security landscape systematically instead of learning defenses ad hoc. graphql.org's security page and the OWASP GraphQL cheat sheet are the two sources to name if the interviewer asks where you learned it.
Category 1: Information disclosure. The attacker discovers the schema (via introspection), undocumented fields (via error suggestion lists), and implementation details (via verbose error responses). Information disclosure is reconnaissance, not the final attack. The defenses are introspection disabling in production, suggestion list disabling, and error response sanitization (returning generic messages for unexpected errors).
Category 2: Denial of service through query shape. The attacker constructs queries that are syntactically valid but computationally expensive: deeply nested queries, queries that fan out across many list fields, and queries that cross-multiply list arguments. The defenses are depth limits, complexity limits, and timeouts. Each defense targets a different attack shape; none is sufficient alone.
Category 3: Batching attacks against rate limiting. The attacker uses GraphQL's batching semantics (multiple operations in one HTTP request, or alias multiplication) to bypass per-request rate limits. A login brute-force that sends 500 password attempts in one GraphQL request bypasses any rate limiter that counts HTTP requests. The defense is per-operation rate limiting, not per-HTTP-request rate limiting.
Category 4: Field-level authorization bypass through traversal. The attacker accesses a private field by including it in a query that traverses from a public entity type. The defenses are field-level authorization in every resolver, row-level authorization in service methods, and schema design that separates public entity types from private entity types.
Category 5: Operational and observability gaps. An attacker who gets through the other defenses should at minimum be detected. This category covers the absence of query logging, the absence of alerting on unusual depth or complexity patterns, the absence of query timeouts, and the absence of incident runbooks. The defense is comprehensive observability: structured query logging, resolver duration metrics, anomaly detection, and a documented response procedure.
A complete answer for this question should not just list the five categories but briefly explain what distinguishes each one and why they don't collapse into a single defense. Depth limits don't catch alias attacks. Alias limits don't catch traversal authorization bypasses. Authorization checks don't catch denial-of-service attacks. The categories are orthogonal, and a production security posture needs to address all five.
| Category | What the attacker does | The defense that answers it |
|---|---|---|
| Information disclosure | Reads the schema through introspection, or harvests field names from suggestion lists and verbose errors | Introspection off in production, suggestions off, sanitized error messages |
| Denial of service by query shape | Nests deeply, or fans out across many list fields | Depth limits, complexity limits, timeouts |
| Batching and alias amplification | Repeats one expensive field under many aliases, or sends many operations in one request | Rate limiting per operation and per field, alias caps |
| Field-level authorization bypass | Reaches a private field by traversing from a public type | Authorization in every resolver, row-level checks in services, private types kept off public paths |
| Operational and observability gaps | Runs any of the above without being noticed | Structured query logging, depth and complexity alerting, a written runbook |
Read the table by column rather than by row. Each defense sits in exactly one row, which is what makes the categories worth memorizing: naming the category tells you which control is missing.
Further Reading
- Security on graphql.org is the official treatment of the attack surface, and it maps closely onto the five categories above.
- Authorization makes the case for putting authorization in the business layer rather than in resolvers, which is the argument Question 5 asks you to weigh.
- Validation explains what the server rejects before execution, which is where depth limits do their work.
- The OWASP GraphQL cheat sheet is the checklist an interviewer is most likely to have read too.
- Serving over HTTP matters for the batching question, because the transport decides what a single request can carry.