Introduction
Spring Security provides built-in support for decoding and validating JSON Web Tokens (JWTs) through its OAuth2 Resource Server module. A JWT consists of three Base64URL-encoded parts — header, payload, and signature — separated by dots. Spring Security's JwtDecoder validates the signature, checks claims like expiration and issuer, and converts the token into an Authentication object. Configuration requires adding the spring-boot-starter-oauth2-resource-server dependency and setting the issuer URI or JWK Set endpoint.
JWT Structure
1eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSIsInNjb3BlIjoicmVhZCJ9.signature
2
3Header: {"alg": "RS256", "typ": "JWT"}
4Payload: {"sub": "user1", "scope": "read", "exp": 1700000000, "iss": "https://auth.example.com"}
5Signature: RSASHA256(base64(header) + "." + base64(payload), privateKey)
Spring Boot Configuration
Add the dependency:
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
4</dependency>
Configure the issuer URI in application.yml:
1spring:
2 security:
3 oauth2:
4 resourceserver:
5 jwt:
6 issuer-uri: https://auth.example.com
7 # Or specify the JWK Set URI directly:
8 # jwk-set-uri: https://auth.example.com/.well-known/jwks.json
Spring Boot auto-configures a JwtDecoder that fetches the public keys from the issuer's JWKS endpoint.
Security Configuration
1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3import org.springframework.security.config.annotation.web.builders.HttpSecurity;
4import org.springframework.security.web.SecurityFilterChain;
5
6@Configuration
7public class SecurityConfig {
8
9 @Bean
10 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
11 http
12 .authorizeHttpRequests(auth -> auth
13 .requestMatchers("/public/**").permitAll()
14 .requestMatchers("/admin/**").hasAuthority("SCOPE_admin")
15 .anyRequest().authenticated()
16 )
17 .oauth2ResourceServer(oauth2 -> oauth2
18 .jwt(jwt -> jwt
19 .jwtAuthenticationConverter(jwtAuthenticationConverter())
20 )
21 );
22 return http.build();
23 }
24
25 // Custom converter to extract authorities from JWT claims
26 @Bean
27 public JwtAuthenticationConverter jwtAuthenticationConverter() {
28 JwtGrantedAuthoritiesConverter grantedAuthorities = new JwtGrantedAuthoritiesConverter();
29 grantedAuthorities.setAuthoritiesClaimName("roles"); // Custom claim name
30 grantedAuthorities.setAuthorityPrefix("ROLE_"); // Prefix for Spring Security
31
32 JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
33 converter.setJwtGrantedAuthoritiesConverter(grantedAuthorities);
34 return converter;
35 }
36}
Custom JwtDecoder Bean
For scenarios where you need a custom decoder (symmetric keys, custom validation):
1import org.springframework.security.oauth2.jwt.JwtDecoder;
2import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
3
4@Bean
5public JwtDecoder jwtDecoder() {
6 // RSA public key
7 return NimbusJwtDecoder.withPublicKey(rsaPublicKey).build();
8
9 // Or HMAC secret key
10 // SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
11 // return NimbusJwtDecoder.withSecretKey(key).build();
12
13 // Or JWK Set URI
14 // return NimbusJwtDecoder.withJwkSetUri("https://auth.example.com/.well-known/jwks.json").build();
15}
Adding Custom Claim Validation
1@Bean
2public JwtDecoder jwtDecoder() {
3 NimbusJwtDecoder decoder = NimbusJwtDecoder
4 .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
5 .build();
6
7 // Add validators
8 OAuth2TokenValidator<Jwt> issuerValidator =
9 JwtValidators.createDefaultWithIssuer("https://auth.example.com");
10
11 OAuth2TokenValidator<Jwt> audienceValidator = token -> {
12 if (token.getAudience().contains("my-api")) {
13 return OAuth2TokenValidatorResult.success();
14 }
15 return OAuth2TokenValidatorResult.failure(
16 new OAuth2Error("invalid_audience", "Expected audience 'my-api'", null)
17 );
18 };
19
20 OAuth2TokenValidator<Jwt> combined = new DelegatingOAuth2TokenValidator<>(
21 issuerValidator, audienceValidator
22 );
23
24 decoder.setJwtValidator(combined);
25 return decoder;
26}
Accessing JWT Claims in Controllers
1@RestController
2@RequestMapping("/api")
3public class UserController {
4
5 @GetMapping("/profile")
6 public Map<String, Object> getProfile(@AuthenticationPrincipal Jwt jwt) {
7 return Map.of(
8 "subject", jwt.getSubject(),
9 "email", jwt.getClaimAsString("email"),
10 "roles", jwt.getClaimAsStringList("roles"),
11 "issuedAt", jwt.getIssuedAt(),
12 "expiresAt", jwt.getExpiresAt()
13 );
14 }
15
16 // Or via SecurityContextHolder
17 @GetMapping("/me")
18 public String getCurrentUser() {
19 JwtAuthenticationToken auth = (JwtAuthenticationToken)
20 SecurityContextHolder.getContext().getAuthentication();
21 Jwt jwt = auth.getToken();
22 return jwt.getSubject();
23 }
24}
Manual JWT Decoding (Without Spring Security)
For decoding without the full security framework:
1import java.util.Base64;
2import com.fasterxml.jackson.databind.ObjectMapper;
3
4public class JwtUtil {
5 private static final ObjectMapper mapper = new ObjectMapper();
6
7 public static Map<String, Object> decodePayload(String token) throws Exception {
8 String[] parts = token.split("\\.");
9 if (parts.length != 3) throw new IllegalArgumentException("Invalid JWT");
10
11 String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
12 return mapper.readValue(payload, Map.class);
13 }
14}
15
16// Usage
17Map<String, Object> claims = JwtUtil.decodePayload(token);
18String subject = (String) claims.get("sub");
This does NOT verify the signature. Only use for debugging or when the signature is verified elsewhere.
Common Pitfalls
Not validating the signature: Decoding the payload with Base64 is trivial — anyone can read JWT claims. The signature validation is what proves the token is authentic and untampered. Always use JwtDecoder with proper key configuration.
Wrong issuer or audience claim: If the iss or aud claim in the token does not match your validation config, Spring Security rejects the token with a generic 401. Check both the token and the config for exact string matches.
Clock skew causing expiration failures: Tokens that expire right at the boundary may fail due to clock differences between the auth server and your application. Configure clock skew tolerance with JwtTimestampValidator(Duration.ofSeconds(60)).
Confusing scope authorities prefix: Spring Security prefixes JWT scopes with SCOPE_ by default. A JWT with "scope": "read write" maps to authorities SCOPE_read and SCOPE_write, not read and write. Use hasAuthority("SCOPE_read") or customize the prefix.
JWKS endpoint caching: NimbusJwtDecoder caches the JWK Set. If the auth server rotates keys, existing tokens may fail until the cache refreshes. Configure cache TTL or implement a JWKSetCache with appropriate expiration.
Summary
Add spring-boot-starter-oauth2-resource-server and set issuer-uri for automatic JWT decoding
Use JwtAuthenticationConverter to map JWT claims to Spring Security authorities
Access JWT claims in controllers with @AuthenticationPrincipal Jwt jwt
Add custom validators for audience, issuer, or domain-specific claims
Never decode JWTs by Base64 alone in production — always validate the signature