Skip to main content

Securing Spring GraphQL APIs - Authentication & Authorization

· 7 min read
GraphQL Guy

GraphQL Security

Your GraphQL API exposes your entire data graph. Learn how to protect it with Spring Security, from authentication to field-level authorization.

Security Challenges in GraphQL

GraphQL presents unique security challenges:

  1. Single endpoint - No URL-based access control
  2. Dynamic queries - Users choose which fields to access
  3. Nested data - Authorization must work at every level
  4. Introspection - Schema can reveal sensitive information

Spring Security integrates seamlessly with Spring GraphQL to address these challenges.

Setting Up Spring Security

Dependencies

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Basic Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable for GraphQL
.authorizeHttpRequests(auth -> auth
.requestMatchers("/graphiql/**").permitAll() // Allow GraphiQL
.requestMatchers("/graphql").authenticated() // Protect GraphQL
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);

return http.build();
}

@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("https://your-auth-server.com");
}
}

Authentication Methods

JWT Authentication

Most common for GraphQL APIs:

@Configuration
public class JwtConfig {

@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withPublicKey(publicKey())
.build();

decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://your-issuer.com"),
new JwtClaimValidator<>("scope", scope ->
scope != null && scope.contains("graphql:read"))
));

return decoder;
}

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles");
converter.setAuthorityPrefix("ROLE_");

JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}

Client request:

curl -X POST http://localhost:8080/graphql \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{"query": "{ books { title } }"}'

API Key Authentication

For service-to-service communication:

@Component
public class ApiKeyAuthFilter extends OncePerRequestFilter {

private final ApiKeyService apiKeyService;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {

String apiKey = request.getHeader("X-API-Key");

if (apiKey != null) {
ApiKeyDetails details = apiKeyService.validate(apiKey);
if (details != null) {
Authentication auth = new ApiKeyAuthentication(details);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}

chain.doFilter(request, response);
}
}

Query-Level Authorization

Method Security

Use Spring Security annotations on controller methods:

@Controller
public class BookController {

@QueryMapping
public List<Book> books() {
// Anyone authenticated can list books
return bookRepository.findAll();
}

@QueryMapping
@PreAuthorize("hasRole('ADMIN')")
public List<Book> allBooksIncludingDrafts() {
return bookRepository.findAllIncludingDrafts();
}

@MutationMapping
@PreAuthorize("hasRole('AUTHOR') or hasRole('ADMIN')")
public Book createBook(@Argument CreateBookInput input) {
return bookService.createBook(input);
}

@MutationMapping
@PreAuthorize("hasRole('ADMIN')")
public DeleteResult deleteBook(@Argument String id) {
return bookService.deleteBook(id);
}
}

Custom Authorization Logic

@Controller
public class BookController {

@MutationMapping
@PreAuthorize("@bookSecurity.canEdit(#id, authentication)")
public Book updateBook(@Argument String id,
@Argument UpdateBookInput input) {
return bookService.updateBook(id, input);
}
}

@Component("bookSecurity")
public class BookSecurityService {

private final BookRepository bookRepository;

public boolean canEdit(String bookId, Authentication auth) {
Book book = bookRepository.findById(bookId).orElse(null);
if (book == null) return false;

// Admins can edit any book
if (hasRole(auth, "ADMIN")) return true;

// Authors can edit their own books
if (hasRole(auth, "AUTHOR")) {
String userId = auth.getName();
return book.authorId().equals(userId);
}

return false;
}

private boolean hasRole(Authentication auth, String role) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}

Field-Level Authorization

The most granular level of control:

Using @SchemaMapping with Security

@Controller
public class UserController {

@SchemaMapping(typeName = "User", field = "email")
@PreAuthorize("@userSecurity.canViewEmail(#user, authentication)")
public String email(User user) {
return user.email();
}

@SchemaMapping(typeName = "User", field = "salary")
@PreAuthorize("hasRole('HR') or @userSecurity.isOwner(#user, authentication)")
public BigDecimal salary(User user) {
return user.salary();
}
}

@Component("userSecurity")
public class UserSecurityService {

public boolean canViewEmail(User user, Authentication auth) {
// Users can see their own email
if (auth.getName().equals(user.id())) return true;
// Admins can see any email
return hasRole(auth, "ADMIN");
}

public boolean isOwner(User user, Authentication auth) {
return auth.getName().equals(user.id());
}
}

Schema Directives Approach

Define authorization in the schema:

directive @auth(requires: Role = USER) on FIELD_DEFINITION

enum Role {
USER
AUTHOR
ADMIN
}

type User {
id: ID!
name: String!
email: String! @auth(requires: USER)
salary: Float @auth(requires: ADMIN)
ssn: String @auth(requires: ADMIN)
}

type Mutation {
createBook(input: CreateBookInput!): Book! @auth(requires: AUTHOR)
deleteBook(id: ID!): Boolean! @auth(requires: ADMIN)
}

Implement the directive:

@Component
public class AuthDirective implements SchemaDirectiveWiring {

@Override
public GraphQLFieldDefinition onField(
SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env) {

GraphQLFieldDefinition field = env.getElement();
GraphQLFieldsContainer parent = env.getFieldsContainer();

// Get required role from directive
String requiredRole = (String) env.getDirective().getArgument("requires").getValue();

// Get original data fetcher
DataFetcher<?> originalFetcher = env.getCodeRegistry()
.getDataFetcher(parent, field);

// Wrap with authorization check
DataFetcher<?> authFetcher = environment -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

if (!hasRequiredRole(auth, requiredRole)) {
throw new AccessDeniedException(
"Requires role: " + requiredRole);
}

return originalFetcher.get(environment);
};

// Register wrapped fetcher
env.getCodeRegistry().dataFetcher(parent, field, authFetcher);

return field;
}

private boolean hasRequiredRole(Authentication auth, String role) {
if (auth == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}

Context Propagation

Make the authenticated user available throughout the request:

@Configuration
public class GraphQLContextConfig {

@Bean
public WebGraphQlInterceptor securityInterceptor() {
return (request, chain) -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

request.configureExecutionInput((input, builder) ->
builder.graphQLContext(context ->
context.put("currentUser", auth)
).build()
);

return chain.next(request);
};
}
}

Access in resolvers:

@Controller
public class BookController {

@QueryMapping
public List<Book> myBooks(DataFetchingEnvironment env) {
Authentication auth = env.getGraphQlContext().get("currentUser");
String userId = auth.getName();
return bookRepository.findByAuthorId(userId);
}
}

Protecting Against Common Attacks

Query Complexity Limits

Prevent resource exhaustion:

@Bean
public Instrumentation maxQueryComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(200); // Max complexity
}

@Bean
public Instrumentation maxQueryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(10); // Max depth
}

Disabling Introspection in Production

@Bean
@Profile("production")
public Instrumentation disableIntrospection() {
return new SimpleInstrumentation() {
@Override
public InstrumentationContext<ExecutionResult> beginExecution(
InstrumentationExecutionParameters parameters) {

String query = parameters.getQuery();
if (query.contains("__schema") || query.contains("__type")) {
throw new AccessDeniedException("Introspection disabled");
}
return super.beginExecution(parameters);
}
};
}

Or via configuration:

spring:
graphql:
schema:
introspection:
enabled: false # Disable in production

Rate Limiting

@Component
public class RateLimitInterceptor implements WebGraphQlInterceptor {

private final RateLimiter rateLimiter;

@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request,
Chain chain) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = auth != null ? auth.getName() : request.getRemoteAddress();

if (!rateLimiter.tryAcquire(userId)) {
return Mono.error(new RateLimitExceededException(
"Rate limit exceeded. Try again later."));
}

return chain.next(request);
}
}

Row-Level Security

Filter data based on user permissions:

@Service
public class BookService {

public List<Book> findAccessibleBooks(Authentication auth) {
Specification<Book> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();

// Everyone sees published books
predicates.add(cb.equal(root.get("status"), "PUBLISHED"));

// Authors see their own drafts
if (hasRole(auth, "AUTHOR")) {
Predicate ownDrafts = cb.and(
cb.equal(root.get("authorId"), auth.getName()),
cb.equal(root.get("status"), "DRAFT")
);
predicates.add(ownDrafts);
}

// Admins see everything
if (hasRole(auth, "ADMIN")) {
return cb.conjunction(); // No filter
}

return cb.or(predicates.toArray(new Predicate[0]));
};

return bookRepository.findAll(spec);
}
}

Audit Logging

Track who accessed what:

@Component
public class AuditInterceptor implements WebGraphQlInterceptor {

private final AuditService auditService;

@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request,
Chain chain) {
long startTime = System.currentTimeMillis();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

return chain.next(request)
.doOnSuccess(response -> {
AuditEvent event = AuditEvent.builder()
.userId(auth != null ? auth.getName() : "anonymous")
.operation(extractOperationName(request))
.query(request.getDocument())
.variables(request.getVariables())
.duration(System.currentTimeMillis() - startTime)
.hasErrors(!response.getErrors().isEmpty())
.timestamp(Instant.now())
.build();

auditService.log(event);
});
}
}

Security Best Practices Checklist

□ Authentication
├── Use JWT or OAuth2 for stateless auth
├── Validate tokens on every request
└── Handle token expiration gracefully

□ Authorization
├── Implement method-level security
├── Add field-level checks for sensitive data
└── Use row-level security for data filtering

□ Input Validation
├── Validate all inputs
├── Sanitize strings to prevent injection
└── Limit input sizes

□ Query Protection
├── Set max query depth
├── Set max query complexity
├── Implement rate limiting
└── Disable introspection in production

□ Monitoring
├── Log authentication failures
├── Track unusual query patterns
└── Alert on security events

Summary

Security LayerImplementation
AuthenticationSpring Security + JWT/OAuth2
Query authorization@PreAuthorize on methods
Field authorization@PreAuthorize on @SchemaMapping
Data filteringRow-level security in repository
Attack preventionComplexity limits, rate limiting

Security in GraphQL requires thinking at multiple levels. Spring Security provides the tools; you provide the rules. Protect your graph like you'd protect a REST API - at every entry point.

Next: Pagination and Filtering - handling large datasets in Spring GraphQL.