Securing Your GraphQL API with Netflix DGS

Part 3 of 7 in the “Production GraphQL with Netflix DGS” series
GraphQL gives clients extraordinary flexibility — they choose which fields to fetch, how deeply to nest, and how many operations to batch. That flexibility is also your attack surface. This article covers the three layers of defense every production GraphQL API needs: authentication and authorization, error sanitization, and query abuse protection.
Layer 1: Authentication and Authorization
DGS runs inside Spring Boot, which means Spring Security’s full toolkit is available. The most practical pattern is @PreAuthorize annotations directly on DGS methods.
Protecting Mutations
Every mutation that changes data should have an explicit authorization annotation:
@DgsComponent
@RequiredArgsConstructor
public class CreateProductMutation {
private final ProductService productService;
private final CurrentUserProvider currentUser;
@DgsMutation
@PreAuthorize("hasAnyRole('MERCHANT', 'ADMIN')")
public Mono<Product> createProduct(@InputArgument CreateProductInput input) {
return currentUser.getUserId().flatMap(userId -> {
return productService.create(input, userId);
});
}
}The key patterns:
@PreAuthorize("isAuthenticated()")— any logged-in user can call this.@PreAuthorize("hasRole('ADMIN')")— only admins.@PreAuthorize("hasAnyRole('MERCHANT', 'ADMIN')")— multiple allowed roles.
These annotations are evaluated before the method body executes. If the check fails, Spring throws AccessDeniedException before any business logic runs.
Getting the Current User
Your auth supplier (however you implement it — from JWT claims, session, or OAuth) should be injected as a Spring bean:
@DgsMutation
@PreAuthorize("isAuthenticated()")
public Mono<Product> updateProduct(@InputArgument UpdateProductInput input) {
return currentUser.getUserId().flatMap(userId -> {
// userId comes from the verified JWT — never from client input
return productService.update(input, userId);
});
}The critical rule: never trust user identity from client input. The user ID should always come from the verified authentication context (JWT claims, session), not from a GraphQL argument. Accepting a userId argument opens the door to IDOR (Insecure Direct Object Reference) attacks.
Ownership Checks
For operations on user-owned resources, verify ownership before proceeding:
@DgsMutation
@PreAuthorize("isAuthenticated()")
public Mono<Order> cancelOrder(@InputArgument String orderId) {
return currentUser.getUserId().flatMap(userId -> {
return orderService.findById(orderId)
.switchIfEmpty(Mono.error(new NotFoundException("Order not found")))
.flatMap(order -> {
if (!userId.equals(order.getOwnerId())) {
return Mono.error(new AccessDeniedException("Not authorized"));
}
return orderService.cancel(orderId);
});
});
}For reusable ownership checks, you can create a service and reference it in SpEL expressions:
@DgsMutation
@PreAuthorize("hasRole('ADMIN') or @ownershipService.ownsOrder(#input.id, authentication.name)")
public Mono<Order> updateOrder(@InputArgument UpdateOrderInput input) {
// ...
}Public vs. Authenticated Queries
Not everything needs authentication. Search, discovery, and detail-view queries are often public:
// Public — no auth required
@DgsQuery
public Mono<ProductPage> products(@InputArgument String searchText, ...) {
return productService.search(searchText, ...);
}
// Authenticated — returns user-specific data
@DgsQuery
@PreAuthorize("isAuthenticated()")
public Mono<OrderPage> myOrders(@InputArgument Integer pageNumber, ...) {
return currentUser.getUserId().flatMap(userId -> {
return orderService.findByOwner(userId, pageNumber, ...);
});
}
// Admin-only — exposes all data with optional owner filtering
@DgsQuery
@PreAuthorize("hasRole('ADMIN')")
public Mono<OrderPage> adminOrders(
@InputArgument String ownerId,
@InputArgument Integer pageNumber, ...) {
return orderService.findAll(ownerId, pageNumber, ...);
}This three-tier pattern is a practical way to organize access control as your API grows:
Layer 2: Error Sanitization
GraphQL errors are returned in the response body, not as HTTP status codes. By default, exception messages go straight to the client. This is a significant information disclosure risk — stack traces, class names, and internal error details can leak to attackers.
The Problem
Without sanitization, a client might receive:
{
"errors": [{
"message": "org.hibernate.ObjectDeletedException: deleted instance passed to merge: [com.example.internal.model.OrderEntity#42]",
"path": ["order"],
"extensions": {
"classification": "DataFetchingException"
}
}]
}This reveals your database technology, internal data model, and transaction behavior. An attacker now knows exactly what to probe.
The Solution: DataFetcherExceptionHandler
DGS lets you register a custom exception handler that intercepts all errors before they reach the client:
@Configuration
public class GraphQLConfig {
@Bean
public DataFetcherExceptionHandler dataFetcherExceptionHandler() {
return new SafeExceptionHandler();
}
}The handler classifies exceptions and returns sanitized messages:
Original message"] CLS -->|AccessDenied| S2["FORBIDDEN
'Access denied'"] CLS -->|NotFound| S3["NOT_FOUND
'Resource not found'"] CLS -->|Everything else| S4["INTERNAL_SERVER_ERROR
'An error occurred'"] S1 --> R["Sanitized GraphQL error response"] S2 --> R S3 --> R S4 --> R style E fill:#ff7043,stroke:#e64a19,color:#fff style H fill:#7c4dff,stroke:#5e35b1,color:#fff style R fill:#00bfa5,stroke:#00897b,color:#fff
public class SafeExceptionHandler implements DataFetcherExceptionHandler {
@Override
public CompletableFuture<DataFetcherExceptionHandlerResult> handleException(
DataFetcherExceptionHandlerParameters params) {
Throwable exception = params.getException();
String path = params.getPath().toString();
// Log the full error for debugging (server-side only)
logException(exception, path);
// Return a sanitized error to the client
String message = sanitize(exception);
String code = classify(exception);
GraphQLError error = GraphqlErrorBuilder.newError()
.message(message)
.path(params.getPath())
.location(params.getSourceLocation())
.extensions(Map.of("code", code))
.build();
return CompletableFuture.completedFuture(
DataFetcherExceptionHandlerResult.newResult().error(error).build());
}
}Classification Strategy
Map exception types to Apollo-compatible error codes. These codes appear in monitoring tools and help you categorize errors without exposing internals:
| Exception Type | Error Code | Client Message | Log Level |
|---|---|---|---|
DomainValidationException | BAD_USER_INPUT | Original message (safe by design) | INFO |
IllegalArgumentException | BAD_USER_INPUT | “Invalid input provided” | INFO |
AccessDeniedException | FORBIDDEN | “Access denied” | WARN |
*NotFoundException | NOT_FOUND | “Resource not found” | INFO |
| Everything else | INTERNAL_SERVER_ERROR | “An error occurred” | ERROR (with stack trace) |
The key insight: validation errors get their original message (because DomainValidationException messages are designed to be user-facing), while unexpected errors get a generic message. The full details are always logged server-side.
Log Levels Matter
Not every exception is an error:
- INFO — validation failures, not-found responses. These are expected. Logging them at ERROR would create alert fatigue.
- WARN — access denied attempts. These deserve investigation but aren’t errors in your code.
- ERROR (with full stack trace) — unexpected exceptions. These are bugs that need fixing.
private void logException(Throwable exception, String path) {
if (exception instanceof DomainValidationException) {
log.info("Validation error", kv("path", path), kv("message", exception.getMessage()));
} else if (exception instanceof AccessDeniedException) {
log.warn("SECURITY: Access denied", kv("path", path), kv("userId", getCurrentUserId()));
} else {
log.error("Unexpected error", kv("path", path), kv("errorType",
exception.getClass().getSimpleName()), exception);
}
}Allowlisting Safe Messages
Some business messages are designed to be shown to users (“Registration is closed”, “Limit reached”). Rather than blocking all messages, you can allowlist patterns that are safe:
private boolean isUserFacingMessage(String message) {
if (message == null) return false;
String lower = message.toLowerCase();
// Block messages that reveal internals
if (lower.contains("exception") || lower.contains("stacktrace")
|| lower.contains("null pointer") || lower.contains("class ")) {
return false;
}
// Allow known safe business messages
return lower.contains("already exists")
|| lower.contains("not found")
|| lower.contains("limit reached")
|| lower.contains("expired")
|| lower.contains("registration is closed");
}This is a blocklist-then-allowlist approach: first reject anything that looks like an implementation detail, then allow known business messages through.
Layer 3: Query Abuse Protection
GraphQL’s flexibility means a single query can be arbitrarily complex. Without limits, an attacker can craft queries that consume disproportionate server resources.
Depth Limiting
A deeply nested query can cause exponential work:
# Malicious query — each level multiplies the work
query {
users {
orders {
products {
reviews {
author {
orders {
products { ... } # Keep going...
}
}
}
}
}
}
}graphql-java provides MaxQueryDepthInstrumentation to reject queries that exceed a configurable nesting level:
@Configuration
public class GraphQLSecurityConfig {
@Value("${graphql.security.max-depth}")
private int maxQueryDepth;
@Bean
public Instrumentation maxQueryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(maxQueryDepth);
}
}When a query exceeds the limit, the client receives an error before any data fetching occurs. The right value depends on your schema — check what depth your legitimate queries actually reach (often 5-8 levels for typical web apps), then add a small buffer.
Complexity Limiting
Depth alone doesn’t capture the full cost. A shallow but wide query (fetching many list fields) can be just as expensive. MaxQueryComplexityInstrumentation assigns costs to fields and rejects queries that exceed a budget:
@Value("${graphql.security.max-complexity}")
private int maxQueryComplexity;
@Bean
public Instrumentation maxQueryComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(maxQueryComplexity);
}The default scoring assigns 1 point per field. List fields multiply by the estimated list size. You can customize scores with field weights for expensive operations, but the defaults are a reasonable starting point.
Making Limits Configurable
Both limits should be externalized to configuration (not hardcoded) so you can tune them per environment:
graphql:
security:
max-depth: ${GRAPHQL_MAX_DEPTH}
max-complexity: ${GRAPHQL_MAX_COMPLEXITY}This lets you run with tighter limits in production and looser limits in development (where deep introspection queries are common). Set your values based on your actual query patterns — there’s no universal right answer.
Security Checklist
When adding a new DGS operation, run through this checklist:
| Check | For Queries | For Mutations |
|---|---|---|
@PreAuthorize annotation | If user-specific | Always |
| User ID from auth context (not input) | If filtering by owner | Always |
| Ownership verification | If returning private data | If modifying owned resources |
| Input validation | For search/filter params | For all inputs |
| Error messages don’t leak internals | Via exception handler | Via exception handler |
| Structured logging for audit trail | Optional | Required |
What’s Next
Your API is now secured against unauthorized access, information leakage, and query abuse. In Part 4, we’ll explore DGS’s real-time capabilities — WebSocket subscriptions and reactive patterns — that let your API push updates to clients as they happen.


