Java

Spring WebFlux Security Guide: OAuth2 Resource Server, JWT, and Token Introspection

Learn Spring WebFlux security with OAuth2 Resource Server, JWT validation, and token introspection to build secure reactive APIs faster.

Spring WebFlux Security Guide: OAuth2 Resource Server, JWT, and Token Introspection

I’ve been building REST APIs with Spring Boot for years, but the first time I tried to secure a reactive WebFlux application, everything fell apart. The security context vanished mid-request. Endpoints that should have been forbidden suddenly let everyone through. My JWT validation worked locally but failed in production. I felt like I was fighting the framework instead of using it. That’s when I realized: reactive security isn’t just a different API — it’s a different way of thinking. This article is the result of those painful lessons. I’ll walk you through exactly how to implement an OAuth2 Resource Server with Spring WebFlux and JWT token introspection, so you don’t make the same mistakes I did.

Why does reactive security feel so different from the servlet-based approach? Because underneath it all, Spring Security switches from a thread‑local SecurityContextHolder to a Reactor context that flows through your reactive pipeline. In a traditional servlet environment, you store authentication in a ThreadLocal and every filter, controller, and service can access it by calling SecurityContextHolder.getContext(). But in WebFlux, there’s no guarantee that the same thread handles the entire request. The reactor context is the only reliable way to pass authentication through an async chain.

When you start a new WebFlux project, the very first dependency decision matters more than you might think. Never include spring-boot-starter-web alongside spring-boot-starter-webflux. I once did this and spent two hours debugging why my reactive security config was completely ignored — Spring Boot defaulted to the servlet stack. The fix was simple: remove the servlet starter. Stick with spring-boot-starter-webflux and spring-boot-starter-security plus the oauth2-resource-server starter. That’s the foundation.

Now, let’s configure the security filter chain. In the reactive world, you return a SecurityWebFilterChain from a bean:

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
            .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
            .authorizeExchange(exchanges -> exchanges
                .pathMatchers("/actuator/health").permitAll()
                .pathMatchers(HttpMethod.GET, "/api/public/**").permitAll()
                .pathMatchers("/api/admin/**").hasRole("ADMIN")
                .anyExchange().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .build();
    }
}

Notice the @EnableReactiveMethodSecurity — without it, @PreAuthorize on your reactive controller methods won’t work. This annotation activates the reactive method security infrastructure, which wraps your methods in a Mono or Flux that checks authorities before execution.

The above configuration assumes you set spring.security.oauth2.resourceserver.jwt.issuer-uri in your application.yml. Spring Security then auto‑discovers the JWKS endpoint from the OpenID Connect metadata. This is the simplest path for JWT validation: your resource server fetches the public keys from the authorization server and verifies the token’s signature locally. There’s no network call per request, so it’s fast.

But what if you need to validate tokens that aren’t JWTs? Or you want to revoke a token immediately without waiting for its expiry? That’s where remote token introspection comes in. Instead of decoding the JWT locally, you send the token to the authorization server’s introspection endpoint and get back a response with the token’s active state and associated claims. Spring Security supports this with the opaque token configuration:

spring:
  security:
    oauth2:
      resourceserver:
        opaque:
          introspection-uri: http://localhost:8080/realms/baeldung/protocol/openid-connect/token/introspect
          client-id: my-resource-server
          client-secret: secret

And in your security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .opaqueToken(Customizer.withDefaults())
)

Now every request triggers a network call to the introspection endpoint. That adds latency, but gives you the ability to invalidate tokens immediately. I’ve used introspection in high‑security environments where stolen JWTs need to be revoked within seconds. The tradeoff is speed – each request now waits for an HTTP roundtrip.

Can you combine both JWT and opaque token validation on the same endpoint? Not easily. Spring Security’s oauth2ResourceServer only accepts one type. If you need to support both, you’ll have to write a custom AuthenticationManagerResolver that inspects the token’s format and delegates accordingly. That’s advanced, but feasible.

Now let’s talk about authority mapping. By default, Spring Security extracts the scope claim from a JWT and prefixes it with SCOPE_. So a token containing scope: read will grant SCOPE_read. That’s fine for simple cases, but often you want to use roles like ROLE_ADMIN or ROLE_USER. You need a custom JwtAuthenticationConverter. Here’s a typical one:

@Component
public class ReactiveJwtAuthenticationConverter implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {

    @Override
    public Mono<AbstractAuthenticationToken> convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
        String principal = jwt.getClaimAsString("sub");
        return Mono.just(new JwtAuthenticationToken(jwt, authorities, principal));
    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        // Assume "realm_access" contains "roles"
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess == null) return List.of();
        @SuppressWarnings("unchecked")
        List<String> roles = (List<String>) realmAccess.get("roles");
        if (roles == null) return List.of();
        return roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
            .collect(Collectors.toList());
    }
}

Then register it in your security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwtSpec -> jwtSpec.jwtAuthenticationConverter(reactiveJwtAuthenticationConverter))
)

When I first built this converter, I forgot to handle null claims. The result was a NullPointerException on every request with a token that didn’t have realm_access. Always add defensive checks.

Now, how do you access the authenticated user inside a controller? In reactive controllers, you can inject Mono<Principal> or Mono<Authentication> directly into the method signature:

@GetMapping("/api/user/profile")
public Mono<Profile> getProfile(@AuthenticationPrincipal Mono<Jwt> jwtMono) {
    return jwtMono.flatMap(jwt -> {
        String userId = jwt.getSubject();
        return userService.findById(userId);
    });
}

The @AuthenticationPrincipal annotation works because Spring Security resolves the Principal from the reactive security context when you declare Mono<Jwt> as a parameter. This is clean and testable.

But what if you need the security context later in a service layer? You can’t inject ReactiveSecurityContextHolder because it’s a static accessor. However, you can retrieve it inside any reactive chain by calling:

ReactiveSecurityContextHolder.getContext()
    .switchIfEmpty(Mono.error(new AuthenticationCredentialsNotFoundException("No context")))
    .flatMap(context -> {
        Authentication auth = context.getAuthentication();
        // use auth
        return Mono.just(someResult);
    })

This is a common pattern when you need to log the user or audit actions deep inside a service. But be careful: if you subscribe to that Mono on a different thread, the context won’t be there. Always stay inside the same reactor assembly.

Error handling in reactive security is also different. Instead of an AccessDeniedHandler in the servlet world, you override the exceptionHandling spec to provide reactive error responses:

.exceptionHandling(exceptions -> exceptions
    .authenticationEntryPoint((exchange, ex) -> {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().writeWith(
            Mono.just(exchange.getResponse().bufferFactory()
                .wrap("{\"error\":\"Unauthorized\"}".getBytes()))
        );
    })
    .accessDeniedHandler((exchange, ex) -> {
        exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
        return exchange.getResponse().writeWith(
            Mono.just(exchange.getResponse().bufferFactory()
                .wrap("{\"error\":\"Forbidden\"}".getBytes()))
        );
    })
)

Notice that each handler returns Mono<Void>. You need to write the response body manually, because there’s no view resolver or serialization built‑in for these error scenarios.

To make sure all of this works, write integration tests using WebTestClient. Here’s an example test for a secured endpoint:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class SecuredControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void whenValidToken_thenOk() {
        // Generate a test JWT (use a test issuer or MockMvcJwt)
        String token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...";

        webTestClient.get()
            .uri("/api/user/profile")
            .headers(h -> h.setBearerAuth(token))
            .exchange()
            .expectStatus().isOk();
    }

    @Test
    void whenNoToken_thenUnauthorized() {
        webTestClient.get()
            .uri("/api/user/profile")
            .exchange()
            .expectStatus().isUnauthorized();
    }
}

You can also use spring-security-test’s @WithMockJwt annotation to simulate authentication without needing an actual token. That makes unit tests faster.

Now, let’s address performance. Remote token introspection adds overhead — each request hits the authorization server. If you have high throughput, consider caching the introspection response. Spring Security does not cache by default, but you can wrap the OpaqueTokenIntrospector with a Caffeine‑based cache. Alternatively, use local JWT validation for most endpoints and only use introspection for sensitive operations.

One common pitfall I see is mixing @EnableWebFluxSecurity with @EnableGlobalMethodSecurity. You must use @EnableReactiveMethodSecurity instead. The two are mutually exclusive.

So, after all that, does your reactive security setup actually work end to end? You can verify by running your application, obtaining a JWT from your IDP (like Keycloak), and hitting the secured endpoints with curl. If you get a 403 for a valid token, double‑check your authority mapping. If you get a 401, inspect the JWT’s signature and issuer.

Building a secure reactive API takes careful thought, but once you internalize the Reactor context propagation and the filter chain differences, it becomes second nature. I no longer dread security configurations — I actually enjoy them, because I know I can deliver both speed and safety.

If you found this article useful, please like it, share it with your team, and leave a comment about your own reactive security challenges. I read every comment and often use them to write follow‑ups. Let’s make the reactive world a little more secure together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Similar Posts

Keep Reading