Spring Boot Tutorial
JWT Authentication with OAuth2 Resource Server
The JWT concepts lesson showed a hand-written filter that parses tokens. In real projects you should not write that filter yourself: token parsing, signature verification, expiry checks, clock skew and error responses are subtle, and Spring Security already implements them correctly in its OAuth2 Resource Server support.
In this lesson you will build a complete stateless JWT setup with Spring Boot 4: a login endpoint that issues signed tokens with JwtEncoder, a resource server that validates them with JwtDecoder, mapping of claims to authorities, and configuration for validating tokens issued by an external identity provider such as Keycloak, Auth0 or Okta.
The Starter and What It Configures
Add spring-boot-starter-security-oauth2-resource-server (renamed from spring-boot-starter-oauth2-resource-server in Boot 4). When you call http.oauth2ResourceServer(o -> o.jwt(...)), Spring Security adds a BearerTokenAuthenticationFilter that reads the Authorization: Bearer header, passes the token to a JwtDecoder, and — if valid — stores a JwtAuthenticationToken in the SecurityContext. Invalid or expired tokens produce a 401 with a WWW-Authenticate: Bearer error="invalid_token" header.
Symmetric vs Asymmetric Signing
With HS256 (HMAC) the same secret signs and verifies tokens. It is simple but every service that verifies tokens must hold the secret, and any of them could therefore also mint tokens. With RS256/ES256 a private key signs and a public key verifies, so only the issuer can create tokens while any service can verify them. Use asymmetric keys whenever more than one service consumes the tokens, and load keys from a secret store — never commit them to source control.
Issuing Tokens with JwtEncoder
If your application is its own token issuer, define a JwtEncoder bean (NimbusJwtEncoder) and build a JwtClaimsSet containing issuer, subject, issued-at, expiry and your custom claims (for example scope or roles). Keep access tokens short-lived — 5 to 15 minutes is common — and use refresh tokens or re-login for longer sessions.
For anything beyond a single application, prefer a dedicated authorization server (see the Spring Authorization Server lesson) rather than issuing tokens from your API.
Validating Tokens from an External Issuer
When tokens come from Keycloak, Auth0, Okta, Azure Entra ID or Spring Authorization Server, you usually need only one property: spring.security.oauth2.resourceserver.jwt.issuer-uri. At startup Spring discovers the issuer's JWK Set URL from its OpenID configuration, downloads the public keys, and validates the signature, expiry and iss claim of every token. Add an audience check so tokens meant for other APIs are rejected.
Mapping Claims to Authorities
By default, values in the scope or scp claim become authorities prefixed with SCOPE_ — so a token with scope: "orders.read" grants SCOPE_orders.read. If your tokens carry roles in a different claim, configure a JwtAuthenticationConverter with a JwtGrantedAuthoritiesConverter pointing at that claim and a ROLE_ prefix. Spring Boot 4.1 also offers spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions to extract authorities from nested claims with SpEL.
Refresh Tokens and Logout
JWT access tokens cannot be revoked once issued, which is why they must be short-lived. A refresh token — long-lived, stored server-side or in an HttpOnly cookie, rotated on every use — lets the client get new access tokens without re-entering a password. "Logout" then means deleting the refresh token; the access token simply expires within minutes.
Examples
pom.xml and application.yml for a self-issuing API
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
# application.yml — keys are loaded from files/secrets, never committed
app:
jwt:
issuer: https://api.webnest.in
ttl: 15m
public-key: file:/run/secrets/jwt-public.pem
private-key: file:/run/secrets/jwt-private.pem
(Generate a key pair for local development with: openssl genpkey -algorithm RSA -out jwt-private.pem -pkeyopt rsa_keygen_bits:2048 && openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem)
Security configuration with JwtEncoder and JwtDecoder beans
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(String issuer, Duration ttl,
RSAPublicKey publicKey, RSAPrivateKey privateKey) {}
@Configuration
@EnableMethodSecurity
@EnableConfigurationProperties(JwtProperties.class)
public class JwtSecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/token").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // no cookies, so no CSRF risk
return http.build();
}
@Bean
JwtDecoder jwtDecoder(JwtProperties props) {
return NimbusJwtDecoder.withPublicKey(props.publicKey()).build();
}
@Bean
JwtEncoder jwtEncoder(JwtProperties props) {
JWK jwk = new RSAKey.Builder(props.publicKey()).privateKey(props.privateKey()).build();
return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
}
}
(Spring Boot converts the PEM files referenced in app.jwt.public-key/private-key into RSAPublicKey/RSAPrivateKey automatically.)
A token endpoint that authenticates the user and issues a JWT
@RestController
@RequestMapping("/api/auth")
public class TokenController {
private final AuthenticationManager authManager;
private final JwtEncoder encoder;
private final JwtProperties props;
public TokenController(AuthenticationManager authManager, JwtEncoder encoder, JwtProperties props) {
this.authManager = authManager;
this.encoder = encoder;
this.props = props;
}
public record TokenRequest(@NotBlank String username, @NotBlank String password) {}
public record TokenResponse(String accessToken, long expiresIn) {}
@PostMapping("/token")
public TokenResponse token(@Valid @RequestBody TokenRequest req) {
Authentication auth = authManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(req.username(), req.password()));
Instant now = Instant.now();
String scope = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replace("ROLE_", "").toLowerCase())
.collect(Collectors.joining(" "));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(props.issuer())
.issuedAt(now)
.expiresAt(now.plus(props.ttl()))
.subject(auth.getName())
.claim("scope", scope)
.build();
String token = encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return new TokenResponse(token, props.ttl().toSeconds());
}
}
curl -X POST localhost:8080/api/auth/token -H "Content-Type: application/json" \
-d '{"username":"asha","password":"user123"}'
{"accessToken":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS53ZWJuZXN0LmluIiwic3ViIjoiYXNoYSIsInNjb3BlIjoidXNlciJ9.kX3...","expiresIn":900}
Using the token and protecting endpoints with scopes
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_user')")
public List<String> myOrders(@AuthenticationPrincipal Jwt jwt) {
return List.of("Order #1001 for " + jwt.getSubject());
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('SCOPE_admin')")
public void delete(@PathVariable Long id) { }
}
curl localhost:8080/api/orders -H "Authorization: Bearer eyJhbGciOi..."
["Order #1001 for asha"]
curl localhost:8080/api/orders (no token) -> 401, WWW-Authenticate: Bearer
curl ... (expired token) -> 401, error="invalid_token", error_description="Jwt expired at 2026-09-27T10:15:00Z"
curl -X DELETE ... (token with scope "user" only) -> 403, error="insufficient_scope"
Validating tokens from Keycloak/Auth0 with audience check and role mapping
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.webnest.in/realms/shop
audiences: orders-api
// Map a "roles" claim to ROLE_ authorities instead of the default SCOPE_ mapping
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
converter.setPrincipalClaimName("preferred_username");
return converter;
}
Token payload: {"iss":"https://auth.webnest.in/realms/shop","aud":"orders-api","preferred_username":"asha","roles":["ADMIN"]}
Resulting Authentication: name=asha, authorities=[ROLE_ADMIN]
Token with aud "billing-api" -> 401 invalid_token (audience mismatch)
Common Mistakes
- Writing a custom JWT parsing filter instead of using oauth2ResourceServer().jwt(), and missing checks such as expiry, algorithm confusion or issuer validation.
- Hard-coding the signing secret in application.properties and committing it to Git.
- Issuing access tokens that last days or weeks; they cannot be revoked, so a stolen token stays valid.
- Forgetting that the default authority prefix is SCOPE_, then wondering why hasRole("ADMIN") never matches.
- Skipping the audience check, which lets a token issued for a different API be replayed against yours.
Key Points to Remember
- Use spring-boot-starter-security-oauth2-resource-server and oauth2ResourceServer(o -> o.jwt(...)) rather than a hand-written filter.
- JwtEncoder issues tokens; JwtDecoder validates signature, expiry and issuer on every request.
- For external identity providers, issuer-uri plus audiences is usually all the configuration you need.
- Scopes map to SCOPE_ authorities by default; customise with JwtAuthenticationConverter.
- Keep access tokens short-lived and use rotated refresh tokens for long sessions.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.