Spring Boot OAuth2 + JWT: End-to-End Zero-Trust API Security

Zero-trust API security means every request is validated independently — no session state, no “trusted network” assumptions. A JWT bearer token is issued by an authorisation server, signed cryptographically, and validated on every API call. The API never calls back to the authorisation server during validation; it verifies the token’s signature locally.

This guide covers the complete setup: dependencies, resource server configuration, token validation (both symmetric and asymmetric), extracting claims, role-based access control, method-level security, and the Spring Security 7 changes that break existing setups.


How JWT Authentication Works

sequenceDiagram
    participant Client
    participant AuthServer as Auth Server\n(Keycloak/Auth0/custom)
    participant API as Spring Boot API

    Client->>AuthServer: POST /token {username, password}
    AuthServer-->>Client: JWT {header.payload.signature}

    Client->>API: GET /orders Authorization: Bearer {JWT}
    API->>API: Verify signature with public key
    API->>API: Check expiry, issuer, audience
    API->>API: Extract roles from claims
    API-->>Client: 200 OK {order data}

    Note over API: No call back to Auth Server\nValidation is local and fast

The JWT contains three parts:

  • Header: algorithm used for signing (RS256, HS256, etc.)
  • Payload: claims (subject, expiry, issuer, roles, custom data)
  • Signature: cryptographic signature — tamper-proof

Spring Boot’s OAuth2 resource server starter handles all of this automatically once configured.


Dependencies

<!-- pom.xml -->
<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>

The oauth2-resource-server starter includes the Nimbus JOSE + JWT library for token parsing and validation.


If you use Keycloak, Auth0, Okta, Google, or any OIDC-compliant provider, Spring Boot can automatically fetch the public key and validate tokens with a single property:

# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-server.com/realms/myrealm

Spring Boot fetches the provider’s JWKS (public key set) from {issuer-uri}/.well-known/openid-configuration, caches it, and uses it to validate every token. When the provider rotates keys, Spring Boot fetches the new key automatically.

The security configuration is minimal:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable()); // safe for stateless JWT APIs
        return http.build();
    }
}

Option 2: Validate with a Symmetric Key (HMAC)

For simpler setups where your application both issues and validates tokens (no separate auth server), use a shared secret:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.decoder(jwtDecoder()))
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        // Secret must be at least 256 bits (32 characters) for HS256
        SecretKeySpec key = new SecretKeySpec(jwtSecret.getBytes(), "HmacSHA256");
        return NimbusJwtDecoder.withSecretKey(key).build();
    }

    @Bean
    public JwtEncoder jwtEncoder() {
        SecretKeySpec key = new SecretKeySpec(jwtSecret.getBytes(), "HmacSHA256");
        JWKSource<SecurityContext> jwkSource = new ImmutableSecret<>(key);
        return new NimbusJwtEncoder(jwkSource);
    }
}

Issuing a token:

@Service
public class TokenService {

    private final JwtEncoder jwtEncoder;

    public TokenService(JwtEncoder jwtEncoder) {
        this.jwtEncoder = jwtEncoder;
    }

    public String generateToken(UserDetails user) {
        Instant now = Instant.now();

        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("my-app")
            .issuedAt(now)
            .expiresAt(now.plus(1, ChronoUnit.HOURS))
            .subject(user.getUsername())
            .claim("roles", user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()))
            .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();

        return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }
}

Option 3: Validate with RSA Public Key (Asymmetric)

For production systems where the auth server uses RS256 (RSA), provide the public key directly:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:public-key.pem

Or configure programmatically:

@Bean
public JwtDecoder jwtDecoder() throws Exception {
    RSAPublicKey publicKey = (RSAPublicKey) KeyFactory
        .getInstance("RSA")
        .generatePublic(new X509EncodedKeySpec(
            Base64.getDecoder().decode(rsaPublicKey)
        ));
    return NimbusJwtDecoder.withPublicKey(publicKey).build();
}

Extracting Claims in Controllers

Once a request is authenticated, access the JWT claims via @AuthenticationPrincipal:

@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/profile")
    public UserProfile getProfile(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();
        String email = jwt.getClaimAsString("email");
        List<String> roles = jwt.getClaimAsStringList("roles");

        return userService.getProfile(userId);
    }

    @GetMapping("/orders")
    public List<Order> getOrders(@AuthenticationPrincipal Jwt jwt) {
        // Subject is typically the user ID
        return orderService.findByUserId(jwt.getSubject());
    }
}

Role-Based Access Control

HTTP-Level Role Checks

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/public/**").permitAll()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MODERATOR")
    .anyRequest().authenticated()
);

Custom JWT Claim Mapping

By default, Spring Security looks for roles in the scope or scp claim. If your JWT stores roles in a different claim (e.g., roles or realm_access.roles in Keycloak), configure a custom converter:

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthoritiesClaimName("roles");      // read from "roles" claim
        converter.setAuthorityPrefix("ROLE_");           // prefix for hasRole() checks

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

For Keycloak, which nests roles in realm_access.roles:

@Bean
public JwtAuthenticationConverter keycloakConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess == null) return Collections.emptyList();

        @SuppressWarnings("unchecked")
        List<String> roles = (List<String>) realmAccess.get("roles");
        if (roles == null) return Collections.emptyList();

        return roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
            .collect(Collectors.toList());
    });
    return converter;
}

Method-Level Security

Enable method security to enforce access control at the service layer:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // enables @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig { ... }
@Service
public class OrderService {

    // Only ADMIN can see all orders
    @PreAuthorize("hasRole('ADMIN')")
    public List<Order> findAll() {
        return orderRepository.findAll();
    }

    // Users can only see their own orders; ADMIN can see any
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.subject")
    public List<Order> findByUser(String userId) {
        return orderRepository.findByUserId(userId);
    }

    // After fetching, check that the returned object belongs to the caller
    @PostAuthorize("returnObject.userId == authentication.principal.subject or hasRole('ADMIN')")
    public Order findById(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }

    // Restrict who can cancel orders
    @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT') or #order.userId == authentication.principal.subject")
    public void cancel(Order order) {
        order.cancel();
        orderRepository.save(order);
    }
}

Method security is enforced regardless of how the method is called — through a web request, from a scheduled job, or from another service. It is the right place for access control logic that needs to apply universally.


Testing Secured Endpoints

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    @WithMockUser(roles = "USER")
    void shouldReturnOrdersForAuthenticatedUser() throws Exception {
        given(orderService.findByUser("testuser")).willReturn(List.of(new Order()));

        mockMvc.perform(get("/api/orders"))
            .andExpect(status().isOk());
    }

    @Test
    void shouldReturn401ForUnauthenticatedRequest() throws Exception {
        mockMvc.perform(get("/api/orders"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void shouldReturn403WhenUserAccessesAdminEndpoint() throws Exception {
        mockMvc.perform(get("/admin/users"))
            .andExpect(status().isForbidden());
    }
}

For testing with a real JWT token:

@Test
void shouldAcceptValidJwtToken() throws Exception {
    String token = generateTestToken("user-123", List.of("ROLE_USER"));

    mockMvc.perform(get("/api/orders")
            .header("Authorization", "Bearer " + token))
        .andExpect(status().isOk());
}

private String generateTestToken(String subject, List<String> roles) {
    // Use your JwtEncoder bean to generate a token with test claims
    JwtClaimsSet claims = JwtClaimsSet.builder()
        .subject(subject)
        .issuedAt(Instant.now())
        .expiresAt(Instant.now().plus(1, ChronoUnit.HOURS))
        .claim("roles", roles)
        .build();
    return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}

Spring Security 7: What Changed

Three changes affect JWT setups when upgrading to Spring Boot 4 / Spring Security 7.

1. authorizeRequests() is removed:

// Old — compile error in Security 7
http.authorizeRequests()
    .antMatchers("/admin/**").hasRole("ADMIN");

// New — required
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
);

2. CSRF enabled by default for REST APIs:

In Security 7, CSRF protection is enabled for all requests including REST APIs. Stateless JWT APIs must explicitly disable it:

http.csrf(csrf -> csrf.disable());

This is safe when you use JWT bearer tokens (not cookies for authentication). CSRF attacks require the browser to automatically send credentials — bearer tokens in the Authorization header are not sent automatically by browsers, so CSRF is not applicable.

3. antMatchers() removed:

Replace all antMatchers() with requestMatchers(). The behaviour is identical; the method was renamed.


CORS Configuration for Frontend Clients

If your frontend (React, Vue, Angular) runs on a different origin from your API, configure CORS:

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "http://localhost:3000"  // development only
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
// Apply in the security filter chain
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

Complete Security Configuration (Production-Ready)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuerUri;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Permit public endpoints
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/actuator/**").hasRole("ADMIN")  // protect other actuator endpoints
                .anyRequest().authenticated()
            )
            // JWT resource server
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            // Stateless sessions
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            // Disable CSRF for bearer token APIs
            .csrf(csrf -> csrf.disable())
            // CORS
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // Security headers
            .headers(headers -> headers
                .frameOptions(frame -> frame.deny())
                .contentTypeOptions(Customizer.withDefaults())
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000)
                    .includeSubDomains(true)
                )
            );

        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return JwtDecoders.fromIssuerLocation(issuerUri);
    }

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

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

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

Quick Reference

GoalConfiguration
Validate against OIDC providerspring.security.oauth2.resourceserver.jwt.issuer-uri
Validate with symmetric keyNimbusJwtDecoder.withSecretKey(key)
Validate with RSA public keyspring.security.oauth2.resourceserver.jwt.public-key-location
Extract claims in controller@AuthenticationPrincipal Jwt jwt
Role from custom JWT claimCustom JwtAuthenticationConverter
Method-level security@EnableMethodSecurity + @PreAuthorize
Disable CSRF (stateless API)csrf -> csrf.disable()
Testing secured endpoints@WithMockUser(roles = "ADMIN")

The full picture: your auth server issues signed JWTs, your Spring Boot API validates them locally using the public key, extracts roles from the token claims, and enforces access at both the HTTP layer and the method layer. No shared session state, no calls back to the auth server per request — every validation is local and fast.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.