skip to content

How do you enforce authentication/authorization in a Spring for GraphQL app, and how does the Spring Security context reach a data fetcher that may run on a different thread?

level: seniorimportance: should knowfreq 30%

answer

  1. two layers: endpoint chain + method security
  2. @EnableMethodSecurity + @PreAuthorize per field
  3. SecurityContext carried in GraphQLContext
  4. context-propagation restores it off-thread
  5. WebSocket: auth at handshake/connection_init

basics

~20 s

Authenticate at the /graphql endpoint with Spring Security's normal HTTP filter chain. Authorize per field with @PreAuthorize on controller methods. Spring for GraphQL propagates the SecurityContext to data fetchers via context propagation, so method security still works even off-thread.

solid answer

~40 s

There are two layers. First, transport security: Spring Security's filter chain protects the single /graphql (and WebSocket) endpoint — you authenticate there (JWT, session, OAuth2) exactly as for any HTTP request, since GraphQL is just POST /graphql. Second, fine-grained authorization inside GraphQL: you can't map URLs to fields, so you use method security — @EnableMethodSecurity plus @PreAuthorize/@Secured on @QueryMapping/@SchemaMapping controller methods. For this to work when fetchers run on separate/reactive threads, Spring for GraphQL integrates the SecurityContext into the GraphQLContext and uses Micrometer context-propagation (a registered ThreadLocalAccessor) to restore it around each fetcher invocation. On WebSocket you authenticate at the handshake/connection_init because per-message HTTP headers aren't available afterward. Prefer field-level @PreAuthorize over one coarse endpoint rule so partial results/denials are handled per field.

code

java · 26 lines
java
@Configuration
@EnableMethodSecurity                       // turns on @PreAuthorize
class SecurityConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(a -> a.requestMatchers("/graphql").authenticated()
                                         .anyRequest().permitAll())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .csrf(c -> c.disable())
            .build();
    }
}

@Controller
class ReportController {

    @QueryMapping
    @PreAuthorize("hasRole('ANALYST')")     // per-field authorization
    public Report report(@Argument String id) {
        // SecurityContext is restored here even if this runs off-thread,
        // thanks to context propagation from the GraphQLContext
        return reportService.load(id);
    }
}

go deeper

for a junior

Know you secure /graphql with Spring Security and use @PreAuthorize for per-field checks.

for a middle

Explain the two layers (endpoint auth vs method security) and that GraphQL returns partial data with a FORBIDDEN error on denial.

for a senior

Detail SecurityContext propagation to off-thread fetchers via context-propagation and WebSocket handshake-time auth.

for a principal

Reason about introspection hardening, CSRF posture for single-endpoint APIs, partial-result authorization semantics, and multi-tenant/authz architecture across transports.

**Two distinct concerns: authentication vs. authorization, at two layers.** **Layer 1 — Endpoint (transport) security.** Because the whole API is one URL (`POST /graphql`, plus the WebSocket path), Spring Security treats it like any HTTP endpoint. You configure a `SecurityFilterChain` to require authentication for `/graphql`: ```java @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a .requestMatchers("/graphql").authenticated() .anyRequest().permitAll()) .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) .csrf(csrf -> csrf.disable()); // typical for token-based APIs return http.build(); } ``` The filter chain establishes the `SecurityContext` (the authenticated `Authentication`) before the GraphQL request is dispatched. This handles *who you are* but can't express *which fields you may read* — every operation shares the same URL. **Layer 2 — Field/method-level authorization.** GraphQL needs per-field rules, so you use **Spring Security method security**. Enable it and annotate controller handlers: ```java @EnableMethodSecurity // on a config class @Controller class AdminController { @QueryMapping @PreAuthorize("hasRole('ADMIN')") public List<AuditEntry> auditLog() { ... } } ``` `@PreAuthorize` (SpEL) and `@Secured` run before the method; a failure raises `AccessDeniedException`, which Spring for GraphQL maps to a GraphQL error (typically `FORBIDDEN`) placed in the `errors` array for that field, while other fields can still resolve. **The threading problem and its solution.** Spring Security stores the `SecurityContext` in a **ThreadLocal** (`SecurityContextHolder`). But GraphQL data fetchers frequently execute **on a different thread** — reactive pipelines, `@Async`, `CompletableFuture`, `DataLoader` batching, or the WebFlux event loop. A naive ThreadLocal lookup on that thread would be empty and `@PreAuthorize` would see an anonymous/absent principal. Spring for GraphQL solves this with **context propagation**: - The `SecurityContext` is captured and put into the per-execution **`GraphQLContext`**. - Spring for GraphQL relies on the **Micrometer context-propagation** library and registered **`ThreadLocalAccessor`** implementations (including one for Spring Security's `SecurityContext`, and reactive `ReactiveSecurityContextHolder` on the WebFlux side). Around each fetcher invocation, the accessor **restores** the ThreadLocal from the context and **clears** it afterward, so `@PreAuthorize`/`SecurityContextHolder.getContext()` work correctly no matter which thread runs the fetcher. In other words: capture-on-request-thread, restore-around-each-fetcher. You generally get this for free by having spring-security on the classpath with GraphQL; you don't hand-wire the accessor. **WebSocket specifics.** For subscription connections over WebSocket, HTTP headers (e.g. the `Authorization` bearer token) are only present at the **handshake / `connection_init`** message. After the socket is open, individual `subscribe` messages carry no HTTP headers. So you authenticate **once at connection time** — read the token in a `WebGraphQlInterceptor` (or a handshake interceptor) and establish the security context for the connection's lifetime. **Gotchas / when-to-use.** - **CSRF**: token-based APIs typically disable CSRF for `/graphql`; session-cookie apps must keep CSRF and send the token, since one POST endpoint is otherwise a CSRF target. - **Introspection & schema exposure**: consider disabling introspection in production so unauthenticated clients can't map your schema. - Don't rely solely on a single endpoint-level `authenticated()` rule for authorization — it's all-or-nothing. Layer `@PreAuthorize` for real per-field control. - Denied fields produce **partial responses** (data for allowed fields, error entries for denied ones) — clients must handle that. - Avoid reading `SecurityContextHolder` inside a `@BatchMapping`/`DataLoader` on a pooled thread without relying on the provided propagation; trust the framework's accessor rather than caching principals yourself.

  • If @PreAuthorize denies one field in a multi-field query, what does the client receive?
    A partial response: data for the fields that resolved successfully, plus an entry in the errors array (typically classified FORBIDDEN) for the denied field. The whole request isn't rejected with a 403; GraphQL returns 200 with mixed data/errors.
  • Why might a @PreAuthorize check see an anonymous user despite the request being authenticated, and how is that avoided?
    Because the fetcher runs on a different thread where the SecurityContext ThreadLocal is empty. Spring for GraphQL avoids it via Micrometer context-propagation: the SecurityContext is carried in the GraphQLContext and a ThreadLocalAccessor restores it around each fetcher invocation.
  • Where do you authenticate a WebSocket subscription and why not per message?
    At the handshake / connection_init, because HTTP headers like Authorization only exist on the initial upgrade; subsequent subscribe messages carry no HTTP headers. You establish the security context once for the connection lifetime, e.g. in a WebGraphQlInterceptor reading the init payload.

saying these in an interview costs you the question

  • Thinking URL-based authorizeHttpRequests rules can protect individual GraphQL fields
  • Assuming SecurityContextHolder always works in a data fetcher without context propagation
  • Believing a denied field returns HTTP 403 for the whole request
  • Trying to read the Authorization header on every WebSocket subscribe message
  • Leaving CSRF enabled/without token handling and not realizing a single POST endpoint is a CSRF surface

context