Spring for GraphQL: Transports, Security, and Testing
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
This page rounds out the Spring for GraphQL series with three operational concerns that sit around the
schema and resolvers already covered elsewhere: which transports a Spring for GraphQL server can speak and how
to intercept requests on each, how Spring Security integrates with controller methods and propagates the
authenticated principal into batched and reactive resolvers, and how to test a server end to end with
GraphQlTester while keeping an eye on observability and native-image support.
Server transports
HTTP, WebSocket, SSE, and RSocket
Spring for GraphQL is transport-agnostic at its core — ExecutionGraphQlService executes a parsed request and
returns a result with no knowledge of how the request arrived — and ships adapters for four transports on top
of that core:
| Transport | Typical use |
|---|---|
HTTP ( |
Queries and mutations; the default for most clients. Covered in depth in Serving GraphQL over HTTP. |
WebSocket ( |
Subscriptions, using the |
Server-Sent Events ( |
A single subscription’s stream of results over a unidirectional, HTTP-native channel — no upgrade handshake, simpler infrastructure than WebSocket where only one subscription’s data needs to flow to the client. |
RSocket |
Request-stream and request-response interaction models over a binary, multiplexed protocol; more common between internal services than from a browser client. |
Each transport is auto-configured from the corresponding Spring Boot starter and spring.graphql.*
properties — spring-boot-starter-graphql already wires up HTTP; WebSocket needs
spring.graphql.websocket.path; RSocket needs the RSocket starter plus spring.graphql.rsocket.mapping — without changing a single controller or resolver, since the same @Controller handler methods answer requests
regardless of which transport carried them in. Subscriptions specifically, and the graphql-transport-ws
handshake they rely on, are covered in Subscriptions; this page focuses
on the transport-level plumbing common to all four.
WebGraphQlInterceptor and WebSocketGraphQlInterceptor
A WebGraphQlInterceptor is Spring for GraphQL’s transport-level equivalent of a servlet filter or a WebFlux
WebFilter: it wraps every request — HTTP, SSE, or WebSocket alike — before it reaches
ExecutionGraphQlService, and can inspect or rewrite the request, add to the per-request graphql.GraphQLContext,
or short-circuit the chain entirely:
@Component
public class TenantHeaderInterceptor implements WebGraphQlInterceptor {
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
String tenantId = request.getHeaders().getFirst("X-Tenant-Id");
if (tenantId == null) {
WebGraphQlResponse response = request.error(
Map.of("message", "Missing X-Tenant-Id header"), null);
return Mono.just(response);
}
request.configureExecutionInput((executionInput, builder) ->
builder.graphQLContext(Map.of("tenantId", tenantId)).build());
return chain.next(request);
}
}
Any number of WebGraphQlInterceptor beans can be registered; Spring for GraphQL chains them in declaration
order (or the order given by @Order), each one calling chain.next(request) to continue and free to
transform the eventual WebGraphQlResponse on the way back out. WebSocketGraphQlInterceptor extends the same
contract with two WebSocket-specific hooks — handleConnectionInitialization, invoked once for the
graphql-transport-ws ConnectionInit message (the natural place to authenticate a WebSocket session and stash
a principal for the lifetime of the connection), and handleDisconnect, invoked when the socket closes — since
a WebSocket connection, unlike an HTTP request, spans many GraphQL operations and needs its own
connection-scoped lifecycle rather than a per-request one.
CORS and CSRF
Both concerns are already covered end to end for GraphQL’s single-endpoint, POST-by-default transport in
Serving GraphQL over HTTP — the CorsRegistry/CorsWebFilter
configuration a GraphQL /graphql endpoint needs is identical to any other Spring MVC/WebFlux endpoint’s, as
shown for REST controllers in REST APIs with Spring MVC and WebFlux,
and CSRF protection matters specifically because a browser can still issue a "simple", non-preflighted POST
with a text content type to /graphql without CORS ever entering the picture. Nothing about the WebSocket or
SSE transports changes that guidance: a WebSocket upgrade request is itself an HTTP request subject to the same
Origin checks, so an allowlisted-origins policy configured once at the Spring Security / CorsConfigurationSource
level, per REST APIs with Spring MVC and WebFlux's CORS section, protects
every transport rather than needing a transport-specific copy.
Spring Security’s default CSRF protection already covers /graphql like any other state-changing POST
endpoint once cookie-based session authentication is in play; the one GraphQL-specific decision is whether the
endpoint should require the CSRF token at all, which depends entirely on how clients authenticate:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf
// Bearer-token clients never send the session cookie, so exempting them from
// CSRF is safe; a browser client using session cookies should NOT be exempted here.
.ignoringRequestMatchers(new AntPathRequestMatcher("/graphql")))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/graphql").authenticated()
.anyRequest().permitAll())
.build();
}
}
Exempt /graphql from CSRF only when every client authenticates with a bearer token or another mechanism a
browser cannot forge on a victim’s behalf (the same reasoning Serving
GraphQL over HTTP applies); a browser-facing endpoint that relies on session cookies needs the token enforced
like any other cookie-authenticated endpoint, GraphQL or not.
Spring Security integration and exception handling
Securing controller methods with @PreAuthorize
@QueryMapping, @MutationMapping, @SubscriptionMapping, and @SchemaMapping handler methods are ordinary
Spring-managed bean methods, so method security — @PreAuthorize, @PostAuthorize, @Secured — applies to
them exactly as it would to any @Service method, once @EnableMethodSecurity is active:
@Controller
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@QueryMapping
@PreAuthorize("isAuthenticated()")
public Order order(@Argument Long id) {
return orderService.findById(id);
}
@MutationMapping
@PreAuthorize("hasRole('ADMIN')")
public Order cancelOrder(@Argument Long id) {
return orderService.cancel(id);
}
@SchemaMapping(typeName = "Order", field = "internalNotes")
@PreAuthorize("hasPermission(#order, 'read:internalNotes')")
public String internalNotes(Order order) {
return order.getInternalNotes();
}
}
Because a single query can select fields resolved by several different @SchemaMapping methods, an access
decision denied on one field surfaces as a DataFetcherExceptionResolver-handled error for just that field’s
path rather than failing the whole request — consistent with the per-field granularity covered generally in
Authorization. A @PreAuthorize failure at the root Query/Mutation
level, by contrast, prevents that root field (and everything beneath it) from resolving at all, the same
distinction Authorization draws between rejecting a whole request and
nulling out one field.
SecurityContext propagation to `DataLoader`s and reactive resolvers
Spring Security’s SecurityContext is normally held in a ThreadLocal, which does not automatically follow
execution onto the separate threads a DataLoader batch callback or a reactive (Mono/Flux) resolver chain
may run on. Spring for GraphQL addresses this with SecurityContextThreadLocalAccessor (registered as a
ThreadLocalAccessor on GraphQlSource.Builder) on the servlet stack, which propagates the current
SecurityContext into the GraphQLContext at the start of execution and restores it onto whichever thread a
DataLoader or blocking resolver subsequently runs on:
@Configuration
public class GraphQlSecurityConfig {
@Bean
public ThreadLocalAccessor securityContextThreadLocalAccessor() {
return new SecurityContextThreadLocalAccessor();
}
}
On the reactive (WebFlux) stack, the equivalent propagation happens through the Reactor Context instead of a
ThreadLocal: ReactiveSecurityContextHolder makes the authenticated principal available as part of the
subscriber context that flows through a Mono/Flux chain, so a @SchemaMapping method returning
Mono<Order> can pull the current Authentication out of context rather than a thread-bound holder:
@SchemaMapping
public Mono<String> internalNotes(Order order) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> authorizationService.canReadNotes(auth, order)
? Mono.just(order.getInternalNotes())
: Mono.empty());
}
Either way, the goal is the same one Authorization describes for any GraphQL server: authenticate once, at the transport boundary, and make the resulting principal available to every resolver and batch loader a request’s execution touches — without each one re-deriving or re-verifying it — regardless of which thread or reactive operator actually runs that resolver’s code.
Exception handling: DataFetcherExceptionResolver and @GraphQlExceptionHandler
An exception thrown out of a DataFetcher — including one thrown by a Spring Security method-security
annotation such as @PreAuthorize’s `AccessDeniedException — is, by default, turned into a generic GraphQL
error with no detail beyond a message. A DataFetcherExceptionResolver bean intercepts exceptions globally and
maps them to a specific GraphQLError, including a meaningful extensions.code:
@Component
public class SecurityExceptionResolver implements DataFetcherExceptionResolver {
@Override
public Mono<List<GraphQLError>> resolveException(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof AccessDeniedException) {
GraphQLError error = GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.FORBIDDEN)
.message("You are not authorized to perform this operation")
.build();
return Mono.just(List.of(error));
}
return Mono.empty(); // let another resolver, or the default handling, take it
}
}
@GraphQlExceptionHandler offers the same mapping at a finer, per-controller granularity — analogous to
@ExceptionHandler in REST APIs with Spring MVC and WebFlux's
centralized-exception-handling section, but scoped to one @Controller (or a @ControllerAdvice-style shared
class) rather than a global bean:
@Controller
public class OrderController {
@QueryMapping
public Order order(@Argument Long id) {
return orderService.findById(id); // throws OrderNotFoundException
}
@GraphQlExceptionHandler
public GraphQLError handleNotFound(OrderNotFoundException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.build();
}
}
Both mechanisms feed into the same errors array shape and extensions.code convention detailed in
Response and error handling; choose a global
DataFetcherExceptionResolver for exception types that mean the same thing everywhere in the schema (an
authorization failure, a validation exception) and a per-controller @GraphQlExceptionHandler for exceptions
whose translation is specific to that controller’s domain.
Testing, observability, and native images
GraphQlTester variants
GraphQlTester is Spring for GraphQL’s fluent test client: the same request-building and response-assertion
API works whether the test drives the server in-process (fastest, no network) or through a real transport
(closer to production, but slower and requiring a running server):
| Variant | What it exercises |
|---|---|
|
Calls |
|
Issues real HTTP requests against a running server (or a mock |
|
Opens a real |
WebSocketGraphQlTester streams a Flux of responses back for a subscription document, so assertions read as
a Reactor StepVerifier sequence rather than a single request/response pair:
class OrderSubscriptionWebSocketTests {
private WebSocketGraphQlTester graphQlTester;
@BeforeEach
void setUp() {
graphQlTester = WebSocketGraphQlTester.builder("ws://localhost:8080/graphql")
.build();
}
@Test
void orderStatusChangedStreamsUpdates() {
Flux<OrderStatus> statuses = graphQlTester.document("""
subscription { orderStatusChanged(orderId: "42") { status } }
""")
.executeSubscription()
.toFlux("orderStatusChanged.status", OrderStatus.class);
StepVerifier.create(statuses)
.expectNext(OrderStatus.PACKED)
.expectNext(OrderStatus.SHIPPED)
.thenCancel()
.verify(Duration.ofSeconds(5));
}
}
A typical query/mutation test, by contrast, asserts on both the response data and, deliberately, on any expected error:
@GraphQlTest(OrderController.class)
class OrderControllerTests {
@Autowired
private GraphQlTester graphQlTester;
@Test
void orderReturnsRequestedFields() {
graphQlTester.document("""
query { order(id: "42") { id status total } }
""")
.execute()
.path("order.status")
.entity(OrderStatus.class)
.isEqualTo(OrderStatus.SHIPPED);
}
@Test
void cancelOrderWithoutAdminRoleIsForbidden() {
graphQlTester.document("""
mutation { cancelOrder(id: "42") { id status } }
""")
.execute()
.errors()
.expect(error -> error.getErrorType() == ErrorType.FORBIDDEN);
}
}
@GraphQlTest (analogous to @WebMvcTest) loads only the GraphQL-related slice of the application context — controllers, RuntimeWiringConfigurer beans, and the schema — and auto-configures an
ExecutionGraphQlServiceTester or HttpGraphQlTester bean depending on whether the test also brings in a
mock or real web environment.
Micrometer observability
Spring for GraphQL integrates with Micrometer the same way the rest of Spring Boot’s actuator-backed stack
does: each request execution and each DataFetcher invocation can produce an observation (timer plus, when
Micrometer Tracing is on the classpath, a trace span), enabled via
spring.graphql.observability./management.observations. properties rather than any code change to
controllers or resolvers:
management:
observations:
graphql:
request:
enabled: true
tracing:
sampling:
probability: 1.0
The resulting graphql.request (and, where enabled, per-DataFetcher) observations flow into whatever
Micrometer registry the application already exports to — Prometheus, an OTLP collector, and so on — alongside
the HTTP/WebSocket-level observations Spring Boot’s web starters already produce, giving request-level and
field-level latency for a GraphQL endpoint the same way management.metrics/management.tracing already do
for a REST controller.
GraalVM native image support
Spring for GraphQL ships GraalVM reachability metadata so a Spring Boot GraphQL application builds into a
native image with native-image/the Spring Boot Buildpacks-based native build, the same way any other
Spring Boot 4.x / Spring Framework 7.x module does; schema files under src/main/resources/graphql/** and any
custom RuntimeWiringConfigurer/DataFetcher classes need the same reflection-hint attention any other
runtime-reflective code needs when compiled ahead of time. This page only points at native-image support rather
than documenting it in depth — consult the linked reference below for the current state of that integration
before relying on it in production.
Related pages
-
Spring Boot: Getting Started — the starter, schema file layout, and
spring.graphql.*properties this page’s transport and testing configuration builds on. -
Spring Boot: Controllers — the
@QueryMapping/@MutationMapping/@SubscriptionMapping/@SchemaMappingmethods this page secures and tests. -
Spring Boot: Data Loading and Integration — the
@BatchMapping/DataLoadermethodsSecurityContextpropagation covers above. -
Subscriptions — the
graphql-transport-wshandshake the WebSocket transport andWebSocketGraphQlTesterexercise. -
Serving GraphQL over HTTP — the HTTP transport’s request/response shape, CORS, and CSRF details this page’s HTTP section cross-links rather than restating.
-
Authorization — where authorization decisions belong and the per-field/per-type granularity
@PreAuthorizeenforces here. -
Response and error handling — the
errorsarray shape andextensions.codeconvention thatDataFetcherExceptionResolverand@GraphQlExceptionHandlerpopulate. -
REST APIs with Spring MVC and WebFlux — the CORS configuration and
@ExceptionHandler/@ControllerAdvicemodel this page’s GraphQL-specific equivalents build on.