Spring for GraphQL: Transports, Security, and Testing

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

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 (POST/GET to /graphql)

Queries and mutations; the default for most clients. Covered in depth in Serving GraphQL over HTTP.

WebSocket (/graphql, upgraded)

Subscriptions, using the graphql-transport-ws sub-protocol, and optionally queries/mutations over the same long-lived connection.

Server-Sent Events (GET/POST to /graphql, Accept: text/event-stream)

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

ExecutionGraphQlServiceTester

Calls ExecutionGraphQlService directly, in-process — no transport, no serialization, the fastest option for testing resolver and validation logic in isolation.

HttpGraphQlTester

Issues real HTTP requests against a running server (or a mock WebTestClient for a @GraphQlTest slice), exercising interceptors, CORS, and Spring Security together with resolver logic.

WebSocketGraphQlTester

Opens a real graphql-transport-ws WebSocket connection, the variant needed to exercise subscriptions and any WebSocketGraphQlInterceptor logic end to end.

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.

  • 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/@SchemaMapping methods this page secures and tests.

  • Spring Boot: Data Loading and Integration — the @BatchMapping/DataLoader methods SecurityContext propagation covers above.

  • Subscriptions — the graphql-transport-ws handshake the WebSocket transport and WebSocketGraphQlTester exercise.

  • 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 @PreAuthorize enforces here.

  • Response and error handling — the errors array shape and extensions.code convention that DataFetcherExceptionResolver and @GraphQlExceptionHandler populate.

  • REST APIs with Spring MVC and WebFlux — the CORS configuration and @ExceptionHandler/@ControllerAdvice model this page’s GraphQL-specific equivalents build on.