By AndyPublished
Spring Boot JWT Authentication with Spring Security's OAuth2 Resource Server
spring.security.oauth2.resourceserver.jwt.issuer-uri, and declare a SecurityFilterChain with.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())). Spring then reads the bearer token, fetches the issuer's public keys, checks the signature, iss,exp and nbf, and turns scopes intoSCOPE_ authorities. You do not need a hand-written OncePerRequestFilter. This guide covers the Maven dependency (renamed in Spring Boot 4), configuration, roles, issuing your own tokens, and the jjwt versus Nimbus question. Versions checked: Spring Boot 4.1.1 and Spring Security 7.1.1 reference docs, jjwt 0.13.0.How Does JWT Work in Spring Boot?
A client sends Authorization: Bearer <token>. With the resource server configured, the request passes through this chain:
- ·BearerTokenAuthenticationFilter extracts the token and hands a
BearerTokenAuthenticationTokento theAuthenticationManager. - ·JwtAuthenticationProvider calls a
JwtDecoder, which parses the token, selects the key bykid, verifies the signature and runs the claim validators (timestamps with 60 seconds of default clock skew, plus issuer when configured). - ·JwtAuthenticationConverter maps claims to granted authorities. By default it reads
scopeorscpand prefixes each value withSCOPE_. - ·The result is a
JwtAuthenticationTokenwhose principal is the decodedJwt. Authorisation rules then run against its authorities.
Nothing is stored server side. Each request is authenticated from the token alone, which is why expiry and key rotation matter more than they do with sessions (see JWT vs session tokens).
JWT Spring Boot Dependency (Maven)
Spring Boot 4.0 renamed several starters to line up with its new modules. The resource server starter is nowspring-boot-starter-security-oauth2-resource-server. The old name,spring-boot-starter-oauth2-resource-server, still exists but the Boot 4.0 migration guide marks it deprecated. On Spring Boot 3.x, use the old name.
<!-- Spring Boot 4.x --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> <!-- was spring-boot-starter-web --> </dependency> <!-- Spring Boot 3.x --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>
The starter pulls in spring-security-oauth2-resource-server andspring-security-oauth2-jose. The second one holds the Nimbus-based JWT decoding and encoding. You do not need a separate JWT library to validate tokens.
JWT with OAuth2 in Spring Boot: Configure the Issuer
If your tokens come from an OAuth 2.0 or OpenID Connect provider (Keycloak, Okta, Auth0, Cognito, Entra ID, Spring Authorization Server), point Spring at the issuer:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/acme
# optional: skip discovery and name the key set directly
# jwk-set-uri: https://idp.example.com/realms/acme/protocol/openid-connect/certs
audiences: https://api.example.comWith issuer-uri, Spring looks up the provider's metadata (/.well-known/openid-configuration, or the OAuthoauth-authorization-server metadata) when the first JWT arrives, readsjwks_uri, and validates every token's iss against the configured value. If you want to know what that key set looks like, see what a JWKS is.
audiences. Issuer and signature checks prove the provider minted the token; only the aud check proves it was minted for your API. Without it, an access token issued to any other application in the same realm or tenant is accepted. See the audience claim.Spring Boot also accepts public-key-location for a single PEM public key, and it backs off completely if you declare your own JwtDecoder bean, for exampleNimbusJwtDecoder.withIssuerLocation(issuer).build() orNimbusJwtDecoder.withJwkSetUri(uri).build().
JWT with Spring Security: the SecurityFilterChain
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("SCOPE_orders.read")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
return http.build();
}
}In a controller, take the decoded token as the principal rather than parsing the header yourself:
@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of("sub", jwt.getSubject(), "scopes", jwt.getClaimAsString("scope"));
}When validation fails, the resource server returns 401 with a WWW-Authenticate: Bearerheader that carries an error and description. When a valid token lacks the needed authority, it returns 403. For a quick look at the claims your API actually received, paste the token into the jwtdecode.app decoder. It decodes locally in the browser.
JWT Role-Based Authentication in Spring Boot
By default only scopes become authorities, so hasRole("ADMIN") fails even when the token plainly contains "roles": ["ADMIN"]. hasRole looks for the authority ROLE_ADMIN. Tell the converter which claim to read and which prefix to use:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // top-level claim holding a list or space-separated string
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}Spring Boot picks up this bean automatically. Two points catch people out:
- ·You lose scopes when you replace the converter. The converter above yields only
ROLE_authorities. If you need both, write aConverter<Jwt, Collection<GrantedAuthority>>that combines a defaultJwtGrantedAuthoritiesConverterwith your role mapping. - ·Nested claims need custom code.
setAuthoritiesClaimNamereads a top-level claim. Keycloak puts realm roles inrealm_access.roles, so map it yourself:
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
Collection<?> names = realm == null ? List.of() : (Collection<?>) realm.getOrDefault("roles", List.of());
return names.stream()
.map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r))
.toList();
});Scopes describe what the client application is allowed to do; roles describe what the user is. Keep the two prefixes distinct so a rule never confuses them. Scopes vs roles covers the design side.
Issuing Your Own JWTs in Spring Boot
Most tutorials under "jwt with spring boot" build a login endpoint that signs tokens with a shared secret. That works, but it makes your API the identity provider, with password storage, refresh, revocation and key rotation to own. If you only need tokens for your own services, Spring Authorization Server or an external provider is usually less work. When you do issue tokens yourself, Spring Security already has an encoder. Since Spring Security 7.0, NimbusJwtEncoder has builders for a secret key, an RSA key pair or an EC key pair:
@Bean
JwtEncoder jwtEncoder(SecretKey key) { // HS256 by default
return NimbusJwtEncoder.withSecretKey(key).build();
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
}
String issue(Authentication auth) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://api.example.com")
.subject(auth.getName())
.audience(List.of("https://api.example.com"))
.issuedAt(now)
.expiresAt(now.plus(Duration.ofMinutes(15)))
.claim("scope", "orders.read orders.write")
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}Load the secret from configuration or a vault, and make it at least 256 bits for HS256 (RFC 7518 §3.2 requires a key at least as long as the hash output). If more than one service verifies the tokens, prefer an asymmetric key pair (NimbusJwtEncoder.withKeyPair(publicKey, privateKey)) so verifiers never hold signing material; HS256 vs RS256 explains the trade-off. Keep access tokens short and pair them with the refresh token pattern.
JWT Java Library: jjwt vs Nimbus
Both are mature. The choice mostly depends on whether you are already inside Spring Security.
| Aspect | Nimbus JOSE + JWT | jjwt |
|---|---|---|
| Used by | Spring Security itself (NimbusJwtDecoder, NimbusJwtEncoder) | Your own code, directly |
| Comes with the Boot starter | Yes, via spring-security-oauth2-jose | No, add jjwt-api, jjwt-impl and jjwt-jackson |
| JWKS fetching and caching | Built in (issuer-uri, jwk-set-uri) | Not built in; you supply the key or a locator |
| JWE (encrypted tokens) | Supported by Nimbus; not wired up by the resource server | Supported |
| Best fit | Validating tokens from an identity provider; issuing tokens inside Spring | Small services that sign and parse their own HS256/RS256 tokens |
The current jjwt API
jjwt changed its API in 0.12: the set-prefixed builder methods andparseClaimsJws are deprecated. Much of the code you will find online still uses them. The current form, per the 0.13.0 README:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.13.0</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.13.0</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.13.0</version> <scope>runtime</scope> </dependency>
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));
String jws = Jwts.builder()
.subject("user-123")
.issuer("https://api.example.com")
.audience().add("https://api.example.com").and()
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plusSeconds(900)))
.claim("roles", List.of("ADMIN"))
.signWith(key)
.compact();
Claims claims = Jwts.parser()
.verifyWith(key)
.requireIssuer("https://api.example.com")
.requireAudience("https://api.example.com")
.clockSkewSeconds(30)
.build()
.parseSignedClaims(jws)
.getPayload();parseSignedClaims throws a JwtException subtype (ExpiredJwtException, SignatureException,IncorrectClaimException, MissingClaimException) when a check fails. signWith(key) picks the algorithm from the key; for an HMAC key it picks the strongest HS algorithm the key length allows.
JWT in Spring Boot 4: What Changed
Spring Boot 4.0.0 was released on 20 November 2025, built on Spring Framework 7 and Spring Security 7. At the time of writing, the reference documentation covers 4.1.1. For JWT authentication the changes that matter are:
- ·Starter names:
spring-boot-starter-security-oauth2-resource-server,-oauth2-clientand-oauth2-authorization-serverreplace the old names, andspring-boot-starter-webbecomesspring-boot-starter-webmvc. - ·Lambda DSL only: the chained
.and()configuration style was removed in Spring Security 7, so old tutorials usinghttp.csrf().disable().and()will not compile. - ·Encoder builders:
NimbusJwtEncoder.withSecretKeyandwithKeyPairare new in Spring Security 7.0. Previously you built aJWKSourceby hand. - ·Jackson 3: Boot 4 moves to Jackson 3 (
tools.jacksongroup ID), which matters if you plug in custom claim serialisation. Java 17 remains the minimum.
The property names under spring.security.oauth2.resourceserver.jwt and theoauth2ResourceServer(...) configuration are the same in Boot 3 and Boot 4.
Testing JWT-Protected Endpoints
spring-security-test can inject an already-authenticated JWT into MockMvc, so tests do not need a running identity provider:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
mockMvc.perform(get("/orders/42")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
.andExpect(status().isOk());This skips the decoder, so also keep at least one integration test that sends a real signed token through the full chain. When real tokens are rejected, the usual causes are an iss that differs by a trailing slash, a missing audience, or clock drift beyond the 60-second default. The common JWT errors guide lists fixes by message.
Common Spring Boot JWT Mistakes
- ·Writing a custom JWT filter. Many tutorials extend
OncePerRequestFilter, parse the header with jjwt and set theSecurityContextby hand. That duplicates the resource server and usually skips something: the audience check, algorithm pinning, the RFC 6750 error responses, or key rotation. Use the built-in support unless you have a specific reason not to. - ·Setting only jwk-set-uri. Keys alone prove who signed the token, not which issuer configuration it belongs to. Set
issuer-urias well (or add an issuer validator to your ownJwtDecoder) soissis still checked. - ·Short HMAC secrets. A secret such as
"mysecret"is too short for HS256, and libraries reject or warn about it. Generate 32 random bytes or more and store them base64-encoded. - ·Long-lived access tokens. A stateless resource server cannot revoke a token before it expires. Keep access tokens to minutes and handle logout with refresh-token revocation; see JWT logout and revocation.
- ·Accepting ID tokens. An OpenID Connect ID token is addressed to the client application, not your API. If
audiencesis set correctly, ID tokens fail the audience check, which is the right result.
Summary
For JWT authentication in Spring Boot, use Spring Security's OAuth2 Resource Server rather than a custom filter: spring-boot-starter-security-oauth2-resource-server on Boot 4 (thespring-boot-starter-oauth2-resource-server name on Boot 3),issuer-uri plus audiences, andoauth2.jwt(withDefaults()) in the filter chain. Map role claims with aJwtAuthenticationConverter. When you must issue tokens yourself, useNimbusJwtEncoder inside Spring, or the jjwt 0.12+ API (Jwts.builder().subject(), Jwts.parser().verifyWith(key).build().parseSignedClaims()) outside it.