Command Palette

Search for a command to run...

[Spring Boot Basics] JWT Authentication for a Spring Boot REST API: Stateless Login, Issuing and Validating Tokens

Article 34 moved the accounts into a users table and added POST /api/auth/login, which checks the password through the AuthenticationManager and returns the profile, while every API call kept sending HTTP Basic credentials. This article turns that endpoint into a token issuer. A successful login returns a JSON Web Token signed with an RSA private key; later calls send it as Authorization: Bearer …, and Spring Security's resource server support verifies the signature with the public key, checks the expiry and builds the authentication from the claims, with no session and no password check.

The examples use Spring Boot 4.1.1, which brings Spring Security 7.1.1, and Java 21, on an Initializr project with the web, validation, security, OAuth2 resource server, Spring Data JPA and H2 dependencies, with the code of article 34 copied in. The database is in-memory H2, except for one run on an H2 file database, and the app runs on port 8135 instead of the default 8080. The keys are generated with OpenSSL 3, and requests are sent with curl and jq.

A token in three coloured parts, a key under the signature and a check under the header and payload

The design follows the plan for Chapter 5: RS256 with a key pair stored as PEM files, a decoder that Spring Boot configures from the public key, a Nimbus encoder built from the same pair, tokens that live 15 minutes and a roles claim that becomes ROLE_ authorities for article 36. Log excerpts were captured with logging.pattern.console=%logger{0}: %msg%n, and shell commands run from the project root.

Why use a token instead of HTTP Basic on every request?

Article 34 measured what stateless HTTP Basic costs: the password travels with every request, and the server answers each one with a SELECT on users and a cost-10 BCrypt comparison, a median of 55.8 ms against 0.7 ms for a request rejected without credentials. A token moves that work into one request. The login checks the password once and signs a statement, this is alice, with role USER, until 10:01:19; later requests carry the statement, and the server only verifies the signature with the public key and reads the claims.

The finished application of this article, on the same machine, timed with curl's %{time_total} after three warm-up requests:

RequestStatusRequestsMinMedianMax
GET /api/auth/me with a bearer token200301.7 ms2.2 ms3.5 ms
GET /api/auth/me without a token401300.5 ms0.6 ms0.8 ms
POST /api/auth/login2001555.4 ms55.9 ms58.4 ms

The 2.2 ms include the SELECT that me runs to load the profile; authenticating the token added no query. The login still pays one BCrypt check, now once per token instead of once per call. As in article 34, the numbers are indicative and were taken with DEBUG logging on.

The other way to stop sending the password is a server-side session, which article 33's form-login chain still uses for everything outside /api/**. Logging in as alice through its login form with curl answered:

Text
HTTP/1.1 302
Set-Cookie: JSESSIONID=6E7A6E6585AA29B4162EA49D1B80D404; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Location: http://localhost:8135/
Content-Length: 0
Date: Mon, 14 Sep 2026 09:46:21 GMT

The next request sent Cookie: JSESSIONID=6E7A6E6585AA29B4162EA49D1B80D404 and got 200 with Catalogue home. The cookie is only an id: the authenticated SecurityContext stays in the HttpSession on the server, every request looks it up, and every instance behind a load balancer has to reach the same sessions; logging out or expiring the session ends the login at once. A token makes the opposite trade. The server keeps nothing per user and any instance with the public key can verify a token, but there is also nothing on the server to delete, so a token stays valid until its exp. The section on what stateless costs shows that with a disabled user.

Three columns: HTTP Basic sends Authorization Basic YWxpY2U6V29uZGVybGFuZC0yMDI2 and the server keeps the users table and runs a SELECT and a BCrypt check per request, 55.8 ms median; a session cookie sends Cookie JSESSIONID=6E7A6E6585AA29B4162EA49D1B80D404 and the server keeps an HttpSession per login; a JWT bearer sends Authorization Bearer eyJraWQi and the server keeps only public.pem and the validators, 2.2 ms median

What is a JWT? One token taken apart

A JWT (JSON Web Token, RFC 7519) in the signed form used here is three Base64URL strings joined by dots: a header, a payload and a signature. This is the token the finished login endpoint returned to alice; the following sections build that endpoint.

Bash
TOKEN=$(curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8135/api/auth/login | jq -r .accessToken)
tr '.' '\n' <<< "$TOKEN"
Text
eyJraWQiOiJDelhSOTJ3ZHVOdFU4ZFI3OVlvRUZleGFZTXlVbDhXZndpQTl0YlBHU3k0IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxMzUiLCJzdWIiOiJhbGljZSIsImV4cCI6MTc4OTM4MDA3OSwiaWF0IjoxNzg5Mzc5MTc5LCJyb2xlcyI6WyJVU0VSIl19
Jx-p7USeQya_he1mrvB4GEkqxYRUVonSiq-Hp0JzuJdby1cMgGvIFFNc31QCsYD6aeKaMFsz4U666GZnBWzJPDTo-gQdhHt-mpRsajiAnSPamV-bPpg0sRwMn_5PNUNMwFlm28YZv52-nX_xeX9yuri3cViXY0GZdCZ69DNJJF976Hm8wgaFy7qwfBQkEN2HuVgDhII_mBkB1WQ2UKPzs1xX0noToi1RuFiG_YqtE5m-YIAv8Z6Qbr4dWy5TjvFFMiixsxVM8ia9FhxHOacZRYP-UR2STpox-VusnoH1Q8GBsdcg0EMu_G7-Q3eMrJ72V59qJqFwWdMGQhzaOZ4l3Q

Header, payload and signature

Base64URL replaces + and / with - and _ and drops the = padding. Two shortcuts fail on that: jq 1.8's @base64d rejected a Base64URL string with is not valid base64 data, and macOS base64 -d silently dropped the last bytes of unpadded input. Two small shell functions handle both directions:

Bash
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
b64url_decode() { tr '_-' '/+' | awk '{ while (length($0) % 4) $0 = $0 "="; print }' | base64 -d; }
cut -d. -f1 <<< "$TOKEN" | b64url_decode | jq .
cut -d. -f2 <<< "$TOKEN" | b64url_decode | jq .
JSON
{
  "kid": "CzXR92wduNtU8dR79YoEFexaYMyUl8WfwiA9tbPGSy4",
  "typ": "JWT",
  "alg": "RS256"
}
JSON
{
  "iss": "http://localhost:8135",
  "sub": "alice",
  "exp": 1789380079,
  "iat": 1789379179,
  "roles": [
    "USER"
  ]
}
PartMemberValue in this tokenMeaning
headeralgRS256RSASSA-PKCS1-v1_5 signature with SHA-256
headertypJWTmedia type, written by NimbusJwtEncoder
headerkidCzXR92…GSy4key id: the RFC 7638 thumbprint of the public key
payloadisshttp://localhost:8135issuer, one fixed value in this application
payloadsubalicesubject: the username
payloadiat1789379179issued at, in seconds: 2026-09-14T09:46:19Z
payloadexp1789380079expires at, iat + 900: 2026-09-14T10:01:19Z
payloadroles["USER"]the application's own claim, read by article 36's rules

The kid is not random. The thumbprint of public.pem, computed with openssl from the key's modulus and exponent:

Bash
N=$(openssl rsa -pubin -in src/main/resources/certs/public.pem -noout -modulus | cut -d= -f2 | xxd -r -p | b64url)
printf '{"e":"AQAB","kty":"RSA","n":"%s"}' "$N" | openssl dgst -sha256 -binary | b64url
Text
CzXR92wduNtU8dR79YoEFexaYMyUl8WfwiA9tbPGSy4

The signature is binary. It is computed over the ASCII text header.payload exactly as it appears in the token, not over the JSON, and openssl can check it with nothing but the public key:

Bash
cut -d. -f3 <<< "$TOKEN" | b64url_decode > signature.bin
wc -c < signature.bin
printf '%s' "$(cut -d. -f1,2 <<< "$TOKEN")" | openssl dgst -sha256 -verify src/main/resources/certs/public.pem -signature signature.bin
Text
     256
Verified OK

256 bytes is the size of a signature from a 2048-bit RSA key, and 342 Base64URL characters in the token. Spring Security's decoder performs the same verification on every request.

The real token in three colours, header of 106 characters, payload of 128 and signature of 342, next to the decoded header with kid, typ JWT and alg RS256, the decoded payload with iss, sub alice, exp 10:01:19Z, iat 09:46:19Z and roles USER, and the 256-byte signature that private.pem produced over base64url(header).base64url(payload), verified with the public key as Verified OK

Signed, not encrypted

Decoding needed no key. Anyone who sees the token, in a proxy log, the browser's developer tools or a pasted support ticket, reads sub, roles and both timestamps. The payload therefore carries identifiers and nothing secret: no password, no hash, nothing the client itself should not see. The signature protects integrity, not confidentiality; a later section changes one word of the payload and gets a 401. Encrypted JWTs (JWE) are a separate format that this series does not use.

Setting up the resource server starter and an RSA key pair

Adding spring-boot-starter-security-oauth2-resource-server

build.gradle
dependencies {
	// the dependencies of article 34, plus:
	implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test'
}

Initializr's oauth2-resource-server id writes exactly these two lines. What the main starter brings:

Bash
./gradlew dependencies --configuration runtimeClasspath
Text
+--- org.springframework.boot:spring-boot-starter-security-oauth2-resource-server -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-starter-security:4.1.1 (*)
|    \--- org.springframework.boot:spring-boot-security-oauth2-resource-server:4.1.1
|         +--- org.springframework.boot:spring-boot-security:4.1.1 (*)
|         +--- org.springframework.boot:spring-boot:4.1.1 (*)
|         +--- org.springframework.security:spring-security-oauth2-jose:7.1.1
|         |    +--- org.springframework.security:spring-security-core:7.1.1 (*)
|         |    +--- org.springframework.security:spring-security-oauth2-core:7.1.1
|         |    |    +--- org.springframework.security:spring-security-core:7.1.1 (*)
|         |    |    +--- org.springframework:spring-core:7.0.9 (*)
|         |    |    \--- org.springframework:spring-web:7.0.9 (*)
|         |    +--- org.springframework:spring-core:7.0.9 (*)
|         |    \--- com.nimbusds:nimbus-jose-jwt:10.9.1
|         \--- org.springframework.security:spring-security-oauth2-resource-server:7.1.1
|              +--- org.springframework.security:spring-security-core:7.1.1 (*)
|              +--- org.springframework.security:spring-security-oauth2-core:7.1.1 (*)
|              +--- org.springframework.security:spring-security-web:7.1.1 (*)
|              \--- org.springframework:spring-core:7.0.9 (*)
  • nimbus-jose-jwt 10.9.1 parses, signs and verifies the tokens.
  • spring-security-oauth2-jose wraps it in JwtDecoder, JwtEncoder and their Nimbus implementations.
  • spring-security-oauth2-resource-server holds BearerTokenAuthenticationFilter, JwtAuthenticationProvider and JwtAuthenticationConverter.
  • spring-boot-security-oauth2-resource-server is Boot's auto-configuration for them.

The test starter is for Chapter 6, which tests these endpoints with tokens.

Generating the key pair with openssl

Bash
mkdir -p src/main/resources/certs
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out src/main/resources/certs/private.pem
openssl pkey -in src/main/resources/certs/private.pem -pubout -out src/main/resources/certs/public.pem

genpkey printed two lines of progress dots and plus signs and wrote private.pem with mode -rw-------, 1704 bytes; pkey -pubout printed nothing and wrote public.pem, 451 bytes.

Bash
head -1 src/main/resources/certs/private.pem
openssl pkey -in src/main/resources/certs/private.pem -noout -text | head -1
cat src/main/resources/certs/public.pem
Text
-----BEGIN PRIVATE KEY-----
Private-Key: (2048 bit, 2 primes)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu12uaxagOp4pk8OVF0fl
7iu+l2DwPc59R0Dx83bTNe8hRqpncDo3eRvjs8O4IfqlGkbDeMgCvA4jtT0IrKch
uRZwOQTTkub6mm5He4a5QAuhkU7jk4jmXC00RF8WsXsyCH5eNlFgvwvdvX13E4Yb
nAHBJqbrc7sfxrDq72MCPhW33qguDkqm3e6SJyn0q9U9s8RDS4EnbWUnNGrefb/a
CmKSRNWe27mhTnb4peP9GSKi09FMMOVMU8luzgrMLseb+eLK8ntIcpEn5a10bqEG
fZBNfn0H/JfM5NCRNw54KHqm50PbDJDymWzXy5ZsxxhrXQfVxE+JlF9V79kTBNHW
6wIDAQAB
-----END PUBLIC KEY-----
  • private.pem is PKCS#8 (BEGIN PRIVATE KEY). Whoever holds it can create tokens this API accepts, as a later section does from the shell.
  • public.pem is an X.509 SubjectPublicKeyInfo (BEGIN PUBLIC KEY). It can only verify, so it is safe to publish; the one above belongs to the lab.

Keeping the keys under src/main/resources makes the lab reproducible; a real private key never goes into the repository and reaches the application at runtime from a secret store or a mounted file instead.

public-key-location and the JwtDecoder Spring Boot builds

src/main/resources/application.properties
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem 
app.jwt.private-key-location=classpath:certs/private.pem 

The first property is Spring Boot's; app.jwt.private-key-location is the application's own, read by the encoder bean in the next subsection. With --debug, the conditions report shows which decoder Boot created:

Text
   JwtDecoderConfiguration#jwtDecoderByPublicKeyValue matched:
      - Public Key Value Condition found public key location property (KeyValueCondition)
 
   JwtDecoderConfiguration#jwtDecoderByIssuerUri:
      Did not match:
         - OpenID Connect Issuer URI Condition did not find issuer-uri property (IssuerUriCondition)
 
   JwtDecoderConfiguration#jwtDecoderByJwkKeySetUri:
      Did not match:
         - JWK Set URI Condition did not find jwk-set-uri property (JwkSetUriCondition)

The bytecode of JwtDecoderConfiguration in spring-boot-security-oauth2-resource-server 4.1.1 shows the rest. jwtDecoderByPublicKeyValue reads the PEM, removes the BEGIN and END lines, builds an RSAPublicKey from an X509EncodedKeySpec and returns NimbusJwtDecoder.withPublicKey(key).signatureAlgorithm(...) with the one algorithm from spring.security.oauth2.resourceserver.jwt.jws-algorithms, RS256 by default; the singular jws-algorithm is deprecated. KeyValueCondition answers no match as soon as jwk-set-uri or issuer-uri is also set, so adding issuer-uri next to the public key does not add an issuer check to this decoder: Boot switches to the issuer-uri decoder instead, which fetches the issuer's metadata over HTTP with NimbusJwtDecoder.withIssuerLocation.

A lab runner read the decoder's validators by reflection:

Text
JwtDecoder bean: org.springframework.security.oauth2.jwt.NimbusJwtDecoder
  jwtValidator = org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
    org.springframework.security.oauth2.jwt.JwtTypeValidator
      validTypes contains JWT
      allowEmpty = true
    org.springframework.security.oauth2.jwt.JwtTimestampValidator
      clockSkew = PT1M
      allowEmptyExpiryClaim = true
      allowEmptyNotBeforeClaim = true
    org.springframework.security.oauth2.jwt.X509CertificateThumbprintValidator
    failOnError = false

Nimbus checks the algorithm and the signature first; then JwtTypeValidator accepts typ JWT or no typ, JwtTimestampValidator checks exp and nbf with a 60-second clock skew and lets a token without exp through, and X509CertificateThumbprintValidator concerns tokens bound to a client certificate. Nothing in this list reads iss. The section on rejected tokens tests each of these defaults.

A JwtEncoder bean from the same key pair

Boot configures decoding only; the conditions report of this application lists no encoder. The bean goes into article 33's SecurityConfig:

src/main/java/com/example/demo/common/SecurityConfig.java
import java.security.interfaces.RSAPrivateKey; 
import java.security.interfaces.RSAPublicKey; 
 
import org.springframework.beans.factory.annotation.Value; 
import org.springframework.security.oauth2.jwt.JwtEncoder; 
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; 
 
@Configuration
public class SecurityConfig {
 
    // apiSecurityFilterChain, webSecurityFilterChain, passwordEncoder and authenticationManager as in article 34
 
    @Bean
    JwtEncoder jwtEncoder( 
            @Value("${spring.security.oauth2.resourceserver.jwt.public-key-location}") RSAPublicKey publicKey, 
            @Value("${app.jwt.private-key-location}") RSAPrivateKey privateKey) { 
        return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build(); 
    } 
}
  • @Value converts a classpath: location into a key. WebSecurityConfiguration in spring-security-config 7.1.1 declares a static conversionServicePostProcessor() bean, an RsaKeyConversionServicePostProcessor, which adds converters for RSAPublicKey from X.509 PEM and RSAPrivateKey from PKCS#8 PEM, and loads the value as a resource when it is a location. The application started and signed tokens with exactly this code.
  • The public key comes from Boot's property, so the encoder and the decoder cannot end up with different files.
  • NimbusJwtEncoder.withKeyPair(RSAPublicKey, RSAPrivateKey) exists in 7.1.1 next to an overload for EC keys and withSecretKey; its builder offers algorithm(...) and jwkPostProcessor(...). Older examples assemble an RSAKey, a JWKSet and an ImmutableJWKSet by hand for the NimbusJwtEncoder(JWKSource) constructor, which still exists.
  • The kid in the header is the thumbprint computed earlier, and typ: JWT is a constant in the bytecode of NimbusJwtEncoder.

Issuing a token from POST /api/auth/login

The response is a small record:

src/main/java/com/example/demo/user/TokenResponse.java
package com.example.demo.user;
 
public record TokenResponse(String accessToken, String tokenType, long expiresIn) {
}

A service builds the claims and signs them:

src/main/java/com/example/demo/user/TokenService.java
package com.example.demo.user;
 
import java.time.Duration;
import java.time.Instant;
import java.util.List;
 
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
 
@Service
public class TokenService {
 
    private static final String ISSUER = "http://localhost:8135";
    private static final Duration LIFETIME = Duration.ofMinutes(15);
 
    private final JwtEncoder jwtEncoder;
 
    public TokenService(JwtEncoder jwtEncoder) {
        this.jwtEncoder = jwtEncoder;
    }
 
    public TokenResponse issue(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(authority -> authority.startsWith("ROLE_"))
                .map(authority -> authority.substring("ROLE_".length()))
                .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer(ISSUER)
                .subject(authentication.getName())
                .issuedAt(now)
                .expiresAt(now.plus(LIFETIME))
                .claim("roles", roles)
                .build();
        String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
        return new TokenResponse(token, "Bearer", LIFETIME.toSeconds());
    }
}

The login keeps article 34's authenticate call and hands the result to the service:

src/main/java/com/example/demo/user/AuthController.java
    private final UserService userService;
    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService; 
 
    public AuthController(UserService userService, AuthenticationManager authenticationManager) { 
    public AuthController(UserService userService, AuthenticationManager authenticationManager, TokenService tokenService) { 
        this.userService = userService;
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService; 
    }
 
    @PostMapping("/login")
    public UserResponse login(@Valid @RequestBody LoginRequest request) { 
    public TokenResponse login(@Valid @RequestBody LoginRequest request) { 
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
        return UserResponse.from(userService.findByUsername(authentication.getName())); 
        return tokenService.issue(authentication); 
    }

A failed login still reaches article 34's handler for BadCredentialsException and AccountStatusException. Its challenge named a scheme the API is about to stop accepting:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
        return ResponseEntity.of(problem)
                .header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"") 
                .header(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"catalogue\"") 
                .build();
  • roles comes from the authorities of the authenticated token with ROLE_ removed. A temporary log statement after authenticate printed login authorities: [ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T09:46:20.296141Z]]; without the filter, FACTOR_PASSWORD would end up in the claim.
  • issuer is one fixed value. JwtClaimAccessor.getIssuer() returns a java.net.URL in 7.1.1, so the value is written as a URL.
  • JwtEncoderParameters.from(claims) passes no header, and the encoder produced the RS256 header with kid and typ shown earlier.
  • LIFETIME.toSeconds() is the expiresIn of 900 seconds.
Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8135/api/auth/login
Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 633
Date: Mon, 14 Sep 2026 09:46:19 GMT
 
{"accessToken":"eyJraWQiOiJDelhSOTJ3ZHVOdFU4ZFI3OVlvRUZleGFZTXlVbDhXZndpQTl0YlBHU3k0IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxMzUiLCJzdWIiOiJhbGljZSIsImV4cCI6MTc4OTM4MDA3OSwiaWF0IjoxNzg5Mzc5MTc5LCJyb2xlcyI6WyJVU0VSIl19.Jx-p7USeQya_he1mrvB4GEkqxYRUVonSiq-Hp0JzuJdby1cMgGvIFFNc31QCsYD6aeKaMFsz4U666GZnBWzJPDTo-gQdhHt-mpRsajiAnSPamV-bPpg0sRwMn_5PNUNMwFlm28YZv52-nX_xeX9yuri3cViXY0GZdCZ69DNJJF976Hm8wgaFy7qwfBQkEN2HuVgDhII_mBkB1WQ2UKPzs1xX0noToi1RuFiG_YqtE5m-YIAv8Z6Qbr4dWy5TjvFFMiixsxVM8ia9FhxHOacZRYP-UR2STpox-VusnoH1Q8GBsdcg0EMu_G7-Q3eMrJ72V59qJqFwWdMGQhzaOZ4l3Q","tokenType":"Bearer","expiresIn":900}

The body is the token taken apart above, and there is still no Set-Cookie. The response is all the state the client gets.

Validating bearer tokens with oauth2ResourceServer

Replacing httpBasic in the API chain

In the API chain of articles 33 and 34, one line changes:

src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(basic -> basic.authenticationEntryPoint(problemHandler)) 
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) 
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }

The startup log prints the new API chain:

Text
DefaultSecurityFilterChain: Will secure Or [PathPattern [/api/**]] with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter, BearerTokenAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
  • BearerTokenAuthenticationFilter sits where BasicAuthenticationFilter was. It reads Authorization: Bearer, passes the token to JwtAuthenticationProvider, which calls the JwtDecoder, and stores the result in SecurityContextHolder for this request.
  • OAuth2ProtectedResourceMetadataFilter is new in the list. It serves RFC 9728 resource metadata at /.well-known/oauth-protected-resource, a path outside /api/**; in this application a GET on it was answered by the form-login chain with 302 to /login.
  • STATELESS stays: nothing about the login is stored between requests.
  • CSRF protection stays off. Client code adds the bearer header to each call, and a browser never attaches it to a request that another site triggers, so a forged cross-site request arrives without credentials. POST /api/products with the token and no CSRF token answered 201 with {"id":1,"sku":"HB-001","name":"USB-C hub","price":350000}. Article 36 covers when CSRF protection is needed again, for example when a token travels in a cookie.

The public rules still name article 34's two POST paths. Permitting /api/auth/** would open me as well: in a run with that rule, GET /api/auth/me without a token reached the controller with a null Jwt, the NullPointerException went to the ERROR dispatch, and the form-login chain answered it, the trap article 33 described:

Text
HTTP/1.1 302
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=F208904C004135FD236C666867499482; Path=/; HttpOnly
Location: http://localhost:8135/login;jsessionid=F208904C004135FD236C666867499482
Content-Length: 0
Date: Mon, 14 Sep 2026 09:48:00 GMT

GET /api/auth/me with @AuthenticationPrincipal Jwt

The principal is no longer a UserDetails:

src/main/java/com/example/demo/user/AuthController.java
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.security.oauth2.jwt.Jwt; 
 
    @GetMapping("/me")
    public UserResponse me(@AuthenticationPrincipal UserDetails principal) { 
        return UserResponse.from(userService.findByUsername(principal.getUsername())); 
    public UserResponse me(@AuthenticationPrincipal Jwt jwt) { 
        return UserResponse.from(userService.findByUsername(jwt.getSubject())); 
    }
Bash
curl -i -s -H "Authorization: Bearer $TOKEN" http://localhost:8135/api/auth/me
Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 69
Date: Mon, 14 Sep 2026 09:46:19 GMT
 
{"id":1,"username":"alice","email":"alice@example.com","role":"USER"}

The log of that request, without the two Spring MVC lines, including a temporary log statement in me that printed the principal's class, getSubject(), getIssuer(), getClaims(), getHeaders() and the Authentication:

Text
JwtAuthenticationProvider: Authenticated token
BearerTokenAuthenticationFilter: Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter$JwtAuthenticatedPrincipal@3d978783, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-14T09:46:19.475250Z]]]
AuthController: me: principal=org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter$JwtAuthenticatedPrincipal subject=alice issuer=http://localhost:8135 claims={iss=http://localhost:8135, sub=alice, exp=2026-09-14T10:01:19Z, iat=2026-09-14T09:46:19Z, roles=[USER]} headers={kid=CzXR92wduNtU8dR79YoEFexaYMyUl8WfwiA9tbPGSy4, typ=JWT, alg=RS256} | authentication=org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken name=alice authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-14T09:46:19.475250Z]]
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
  • The only SQL is me loading the profile. Authentication read no table: the token was checked with the public key and the claims.
  • The Authentication is a JwtAuthenticationToken, its name is the sub claim, and its credentials are [PROTECTED].
  • The principal is JwtAuthenticationConverter$JwtAuthenticatedPrincipal. javap shows it extends org.springframework.security.oauth2.jwt.Jwt implements OAuth2AuthenticatedPrincipal, which is why @AuthenticationPrincipal Jwt receives it.
  • The claims are converted: exp and iat print as Instants, and roles is a list. The authorities in this log already contain the mapping of the next subsection.

Authorities: SCOPE_ by default, ROLE_ from the roles claim

Before the mapping, the same request logged:

Text
JwtAuthenticationProvider: Authenticated token
BearerTokenAuthenticationFilter: Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter$JwtAuthenticatedPrincipal@1e5ee4d7, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-14T07:05:27.203457Z]]]

Only FACTOR_BEARER: the roles claim was ignored, and article 33's hasRole("ADMIN") rule could never match anyone. The default JwtAuthenticationConverter delegates to JwtGrantedAuthoritiesConverter, whose constants in 7.1.1 include DEFAULT_AUTHORITY_PREFIX = "SCOPE_" and a space as delimiter; it reads OAuth2 scopes, and this token has none. To see the default work, a token with a scope claim was signed from the shell with the same private key. openssl dgst -sha256 -sign produces an RS256 signature:

Bash
sign() { local h p s; h=$(printf '%s' "$1" | b64url); p=$(printf '%s' "$2" | b64url); s=$(printf '%s.%s' "$h" "$p" | openssl dgst -sha256 -sign "$3" -binary | b64url); printf '%s.%s.%s' "$h" "$p" "$s"; }
NOW=$(date +%s)
SCOPED=$(sign '{"alg":"RS256","typ":"JWT"}' "$(printf '{"iss":"http://localhost:8135","sub":"alice","iat":%d,"exp":%d,"scope":"catalogue:read catalogue:write"}' $NOW $((NOW+900)))" src/main/resources/certs/private.pem)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $SCOPED" http://localhost:8135/api/auth/me

The answer was 200, and the log:

Text
BearerTokenAuthenticationFilter: Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter$JwtAuthenticatedPrincipal@42a66e50, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_catalogue:read, FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-14T07:06:19.424766Z], SCOPE_catalogue:write]]

The API accepted a token that the login endpoint never issued, because it carried a valid signature: the private key is the whole trust of this design. Mapping roles instead of scope takes two Boot properties:

src/main/resources/application.properties
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles 
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_ 

Boot 4.1.1 then creates the converter itself:

Text
   JwtConverterConfiguration matched:
      - @ConditionalOnClass found required class 'org.springframework.security.oauth2.jwt.JwtDecoder' (OnClassCondition)
      - AnyNestedCondition 2 matched 2 did not; NestedCondition on JwtConverterConfiguration.PropertiesCondition.OnAuthoritiesExpressions Authorities claim expressions did not find property spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions; NestedCondition on JwtConverterConfiguration.PropertiesCondition.OnAuthoritiesClaimName @ConditionalOnProperty (spring.security.oauth2.resourceserver.jwt.authorities-claim-name) matched; NestedCondition on JwtConverterConfiguration.PropertiesCondition.OnPrincipalClaimName @ConditionalOnProperty (spring.security.oauth2.resourceserver.jwt.principal-claim-name) did not find property 'spring.security.oauth2.resourceserver.jwt.principal-claim-name'; NestedCondition on JwtConverterConfiguration.PropertiesCondition.OnAuthorityPrefix @ConditionalOnProperty (spring.security.oauth2.resourceserver.jwt.authority-prefix) matched (JwtConverterConfiguration.PropertiesCondition)
      - @ConditionalOnMissingBean (types: org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; SearchStrategy: all) did not find any beans (OnBeanCondition)

oauth2.jwt(Customizer.withDefaults()) picked up that JwtAuthenticationConverter bean without any change to the chain, and the request logged Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_BEARER, …]], the line in the previous subsection. Across the timed requests the two authorities appeared in either order. No JwtAuthenticationConverter bean of your own is needed for this mapping; one becomes necessary only for rules the properties cannot express.

  • ROLE_USER is what hasRole("USER") checks. Alice's DELETE /api/products/1 now reached article 33's rule and got its 403 with {"detail":"You are not allowed to perform this operation.","instance":"/api/products/1","status":403,"title":"Forbidden"}. Article 36 builds the authorization rules on these roles.
  • FACTOR_BEARER is FactorGrantedAuthority.BEARER_AUTHORITY in spring-security-core 7.1.1, added for every request authenticated by a bearer token. The FACTOR_PASSWORD of the login request is not in the token and does not come back.

Two lanes: the login sends username and password to POST /api/auth/login, the AuthenticationManager runs DaoAuthenticationProvider with a SELECT and BCrypt at about 56 ms, TokenService builds the JwtClaimsSet, JwtEncoder signs with private.pem and the response is 200 with accessToken, tokenType Bearer and expiresIn 900; the client keeps the token and every later request sends Authorization Bearer to BearerTokenAuthenticationFilter, JwtDecoder verifies the signature with public.pem and runs JwtTypeValidator and JwtTimestampValidator, JwtAuthenticationToken gets ROLE_USER and FACTOR_BEARER, and AuthController.me answers 200 in a 2.2 ms median; an invalid token branches to a 401 with WWW-Authenticate Bearer realm catalogue, error invalid_token and error_description Signed JWT rejected: Invalid signature, plus the ProblemDetail body

When a bearer token is rejected: 401 and WWW-Authenticate

Keeping ProblemDetail bodies with a Bearer challenge

With the chain above, a request without a token and a request with a broken token answered differently:

Bash
curl -i -s http://localhost:8135/api/auth/me
curl -i -s -H 'Authorization: Bearer not-a-jwt' http://localhost:8135/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 07:05:27 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Mon, 14 Sep 2026 07:05:27 GMT

Each is wrong in its own way, for the reason article 33 found with httpBasic. A request without a token passes the filters anonymously and is denied by AuthorizationFilter, so ExceptionTranslationFilter calls the entry point from exceptionHandling: article 33's handler, which still advertises Basic. A token that fails is rejected inside BearerTokenAuthenticationFilter, which calls the entry point configured on oauth2ResourceServer, by default a BearerTokenAuthenticationEntryPoint: correct challenge, no body. There was no redirect this time. The bytecode of BearerTokenAuthenticationEntryPoint.commence in 7.1.1 builds the header from the error, then calls addHeader("WWW-Authenticate", ...) and setStatus(...), never sendError, so no ERROR dispatch reaches the form-login chain.

That also means the Bearer entry point leaves the body free. The handler lets it write the challenge and the status, then writes the ProblemDetail:

src/main/java/com/example/demo/common/ProblemDetailSecurityHandler.java
import org.springframework.http.HttpHeaders; 
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; 
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
    private final BearerTokenAuthenticationEntryPoint bearerEntryPoint = new BearerTokenAuthenticationEntryPoint(); 
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
        this.bearerEntryPoint.setRealmName("catalogue"); 
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\""); 
        write(request, response, HttpStatus.UNAUTHORIZED, "Valid credentials are required to access this resource."); 
        bearerEntryPoint.commence(request, response, ex); 
        write(request, response, HttpStatus.valueOf(response.getStatus()), "Valid credentials are required to access this resource."); 
    }
 
    // handle and write are unchanged from article 33
}
src/main/java/com/example/demo/common/SecurityConfig.java
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) 
                .oauth2ResourceServer(oauth2 -> oauth2 
                        .jwt(Customizer.withDefaults()) 
                        .authenticationEntryPoint(problemHandler)) 
  • bearerEntryPoint.commence adds WWW-Authenticate and sets the status from the error, and write reads that status back instead of assuming 401.
  • setRealmName("catalogue") keeps the realm of articles 33 and 34.
  • The component is set twice, on exceptionHandling for requests without a token and on oauth2ResourceServer for tokens that fail.

The request without a token after a restart:

Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 09:46:19 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}

The challenge and the body survive together. resource_metadata is added by the 7.1.1 entry point and points at the metadata path mentioned earlier. curl -u alice:Wonderland-2026 on the same URL received exactly this response: HTTP Basic is gone from the API chain, and the Basic header is ignored. Every rejected token below produced the same other headers and the same body as this response, so only the status line and the challenge are quoted.

A malformed token

Bash
curl -i -s -H 'Authorization: Bearer not-a-jwt' http://localhost:8135/api/auth/me
curl -i -s -H 'Authorization: Bearer not a jwt' http://localhost:8135/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="Bearer token is malformed", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

The first string reached Nimbus, which could not parse three parts out of it. The second never did: DefaultBearerTokenResolver matches the header against ^Bearer (?<token>[a-zA-Z0-9-._~+/]+=*)$ and rejected the spaces itself. The log of the first:

Text
JwtAuthenticationProvider: Failed to authenticate since the JWT was invalid
ProviderManager: Authentication failed with provider JwtAuthenticationProvider since An error occurred while attempting to decode the Jwt: Malformed token

A payload edited after signing

The attack a signature exists for: take alice's real token, change USER to ADMIN in the payload and keep the original header and signature.

Bash
HEADER=$(cut -d. -f1 <<< "$TOKEN")
SIGNATURE=$(cut -d. -f3 <<< "$TOKEN")
EDITED=$(cut -d. -f2 <<< "$TOKEN" | b64url_decode | sed 's/"USER"/"ADMIN"/')
echo "$EDITED"
curl -i -s -H "Authorization: Bearer $HEADER.$(printf '%s' "$EDITED" | b64url).$SIGNATURE" http://localhost:8135/api/auth/me
JSON
{"iss":"http://localhost:8135","sub":"alice","exp":1789380079,"iat":1789379179,"roles":["ADMIN"]}
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

The signature covers the Base64URL text of the payload, and changing one word of the JSON changed that text.

A token signed with another key

A second key pair re-signs the same header and payload:

Bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out other-private.pem
FOREIGN=$(sign "$(cut -d. -f1 <<< "$TOKEN" | b64url_decode)" "$(cut -d. -f2 <<< "$TOKEN" | b64url_decode)" other-private.pem)
curl -i -s -H "Authorization: Bearer $FOREIGN" http://localhost:8135/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

A well-formed RS256 signature, and even the original kid, is not enough: the decoder knows one public key, and the signature does not verify with it.

An unsigned token with alg none

JWT allows an "unsecured" form with "alg":"none" and an empty signature. Old libraries that trusted the header's alg accepted it, which is why tutorials keep warning about it.

Bash
NOW=$(date +%s)
HEADER=$(printf '%s' '{"alg":"none"}' | b64url)
PAYLOAD=$(printf '{"iss":"http://localhost:8135","sub":"alice","exp":%d,"iat":%d,"roles":["ADMIN"]}' $((NOW+900)) $NOW | b64url)
curl -i -s -H "Authorization: Bearer $HEADER.$PAYLOAD." http://localhost:8135/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="Unsupported algorithm of none", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

Rejected before any signature check. The other classic, a header claiming HS256 with an HMAC computed using the text of public.pem as the secret, got Signed JWT rejected: Another algorithm expected, or no matching key(s) found: the decoder built from an RSA key with RS256 accepts nothing else.

An expired token and the 60-second clock skew

Tokens signed with sign, each with an exp a given number of seconds in the past, each sent the moment it was made:

Bash
for OFFSET in 5 30 59 61 90; do
  NOW=$(date +%s)
  EXPIRED=$(sign '{"alg":"RS256","typ":"JWT"}' "$(printf '{"iss":"http://localhost:8135","sub":"alice","exp":%d,"iat":%d,"roles":["USER"]}' $((NOW-OFFSET)) $((NOW-OFFSET-900)))" src/main/resources/certs/private.pem)
  echo "exp = now - ${OFFSET}s -> $(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $EXPIRED" http://localhost:8135/api/auth/me)"
done
Text
exp = now - 5s -> 200
exp = now - 30s -> 200
exp = now - 59s -> 200
exp = now - 61s -> 401
exp = now - 90s -> 401

A token that expired 59 seconds earlier still worked. The token 90 seconds past its exp, sent at 2026-09-14T09:46:19Z:

Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-09-14T09:44:49Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

This is the clockSkew = PT1M of the default JwtTimestampValidator: a token counts as expired only once the current time minus 60 seconds is past exp. The allowance exists for tokens issued by another server whose clock may differ, and it means a 15-minute token from this application is accepted for up to 16 minutes.

Changing the clock skew and checking iss and exp

Two more gaps showed up with the same sign function. A token with "iss":"https://tokens.example.org" answered 200, and so did a token with no exp claim at all: the default validators accept both, as the reflection output suggested. Boot's JwtDecoderConfiguration takes every OAuth2TokenValidator<Jwt> bean from the context, and when there is at least one it builds the decoder's validator with JwtValidators.createDefaultWithValidators(list) instead of createDefault(). Two beans in SecurityConfig close all three gaps:

src/main/java/com/example/demo/common/SecurityConfig.java
import java.time.Duration; 
 
import org.springframework.security.oauth2.jwt.JwtIssuerValidator; 
import org.springframework.security.oauth2.jwt.JwtTimestampValidator; 
 
    @Bean
    JwtTimestampValidator jwtTimestampValidator() { 
        JwtTimestampValidator validator = new JwtTimestampValidator(Duration.ZERO); 
        validator.setAllowEmptyExpiryClaim(false); 
        return validator; 
    } 
 
    @Bean
    JwtIssuerValidator jwtIssuerValidator() { 
        return new JwtIssuerValidator("http://localhost:8135"); 
    } 

The reflection runner after a restart:

Text
JwtDecoder bean: org.springframework.security.oauth2.jwt.NimbusJwtDecoder
  jwtValidator = org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
    org.springframework.security.oauth2.jwt.JwtTypeValidator
      validTypes contains JWT
      allowEmpty = true
    org.springframework.security.oauth2.jwt.X509CertificateThumbprintValidator
    org.springframework.security.oauth2.jwt.JwtTimestampValidator
      clockSkew = PT0S
      allowEmptyExpiryClaim = false
      allowEmptyNotBeforeClaim = true
    org.springframework.security.oauth2.jwt.JwtIssuerValidator
      validator = org.springframework.security.oauth2.jwt.JwtClaimValidator
        claim = iss
    failOnError = false

One JwtTimestampValidator, the bean: it replaced the 60-second default rather than running next to it. The loop with offsets of 1 and 5 seconds, then a 5-second token, the token without exp and the token from https://tokens.example.org sent again, each shown with its status line and challenge:

Text
exp = now - 1s -> 401
exp = now - 5s -> 401
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-09-14T09:47:55Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: exp is required", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8135/.well-known/oauth-protected-resource"

The 5-second token was sent at 2026-09-14T09:48:00Z. A skew of zero is reasonable here only because the same application, with one clock, issues and checks the tokens; a resource server that accepts tokens from a separate authorization server should keep a small allowance. Only the holder of the private key can create any of these tokens, so the two extra checks guard against mistakes in issuing code or keys shared by more than one issuer, not against strangers.

What stateless costs: a token cannot be revoked

A disabled user's token keeps working

For this run the application used an H2 file database with AUTO_SERVER=TRUE, so that a second process could change a row while it was running:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8135 '--spring.datasource.url=jdbc:h2:file:./build/h2/demo;AUTO_SERVER=TRUE' --spring.datasource.username=sa --spring.jpa.hibernate.ddl-auto=create

carol registered with Hatter-Tea-2026, logged in and received a token with "iat":1789379280 and "exp":1789380180, 09:48:00 to 10:03:00 UTC. Her token on me:

Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 69
Date: Mon, 14 Sep 2026 09:48:00 GMT
 
{"id":2,"username":"carol","email":"carol@example.com","role":"USER"}

An administrator disables her with H2's own shell, using the H2 jar Gradle downloaded:

Bash
H2_JAR=$(find ~/.gradle/caches -name h2-2.4.240.jar | head -1)
java -cp "$H2_JAR" org.h2.tools.Shell -url "jdbc:h2:file:./build/h2/demo;AUTO_SERVER=TRUE" -user sa -sql "update users set enabled = false where username = 'carol'"
java -cp "$H2_JAR" org.h2.tools.Shell -url "jdbc:h2:file:./build/h2/demo;AUTO_SERVER=TRUE" -user sa -sql "select username, enabled from users"
Text
(Update count: 1, 1 ms)
USERNAME | ENABLED
alice    | TRUE
carol    | FALSE
(2 rows, 2 ms)

The same token, four seconds later:

Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 69
Date: Mon, 14 Sep 2026 09:48:04 GMT
 
{"id":2,"username":"carol","email":"carol@example.com","role":"USER"}

POST /api/products with her token also answered 201. Only a new login saw the change:

Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 09:48:04 GMT
 
{"detail":"Invalid username or password","instance":"/api/auth/login","status":401,"title":"Unauthorized"}

That is article 34's response for a disabled account, now with the Bearer challenge. The decoder never reads users, and me loads the entity without looking at enabled, so carol keeps full access until 10:03:00, plus the skew when the default validator is in place. The roles claim behaves the same way: it records the role at login, and a demoted administrator keeps ADMIN in an already issued token.

Short lifetimes, refresh tokens and revocation lists

The lifetime is the revocation delay. Fifteen minutes bounds how long a stolen or outdated token works, at the price of a new login every fifteen minutes for a client that has only this endpoint. Real systems pair a short-lived access token with a longer-lived refresh token that the server stores and can revoke, issued and exchanged by an authorization server such as Spring Authorization Server or Keycloak, or by an external identity provider. Some also keep a list of revoked token ids that every request checks, which brings back part of the server-side state that tokens removed. Refresh tokens, revocation stores and authorization servers are topics for the Advanced course.

Where a browser client keeps the token

A native or server-side client stores the token wherever it keeps other secrets. A browser application has three choices. In memory (a JavaScript variable) the token is gone after a reload, so the user logs in again, but no other page can read it. localStorage survives reloads and is readable by every script on the origin, so one cross-site scripting bug hands the token to an attacker. An HttpOnly cookie set by the server is invisible to JavaScript, but the browser attaches it to requests automatically, which is exactly the property that makes cross-site request forgery possible: with a cookie, CSRF protection has to come back, and the server has to read the token from the cookie instead of the header. Article 36 covers CSRF for that case.

Why not a hand-written JJWT filter?

Many tutorials validate tokens with the JJWT library in a OncePerRequestFilter: read the Authorization header, parse the token with a key, build a UsernamePasswordAuthenticationToken, put it into SecurityContextHolder, catch the exceptions. It works, but everything this article observed then becomes code you own: the rule for extracting a bearer token, the algorithm allow-list that rejected none and HS256, the expiry and not-before checks with their skew, the typ check, the RFC 6750 WWW-Authenticate header with invalid_token and a description, the connection to the entry point that writes the ProblemDetail, and the mapping from claims to authorities. Spring Security's resource server ships all of that, maintained with Spring Security itself, and the application's part is a starter, a property, an encoder bean and one line in the chain. This series uses it for that reason and does not show the hand-written version.

HTTP Basic (article 34)Session cookie (form login)JWT bearer (this article)
Each request carriesAuthorization: Basic with username and password, only Base64-encodedCookie: JSESSIONID=…, a random idAuthorization: Bearer …, signed claims readable by anyone
The server keepsthe users table; nothing about the loginan HttpSession with the SecurityContext per loginthe public key and the validators; nothing per user
Work per requestSELECT and BCrypt: 55.8 ms median at cost 10session lookupsignature check: me in 2.2 ms median, its own SELECT included
Revocationimmediate: a disabled user's next request failsimmediate: invalidate the sessionnone before exp; a disabled user's token still answered 200
CSRF exposurenone from curl or client code that sets the header; a browser may resend Basic credentials it has cachedyes: the browser attaches the cookie automatically, so CSRF protection must stay onnone while the token travels in a header; returns if the token is moved into a cookie

FAQ

Does Spring Boot create the JwtDecoder from public-key-location?

Yes. With spring.security.oauth2.resourceserver.jwt.public-key-location set and neither issuer-uri nor jwk-set-uri, the conditions report of Spring Boot 4.1.1 shows JwtDecoderConfiguration#jwtDecoderByPublicKeyValue matched, and the bean is a NimbusJwtDecoder for RS256 with JwtTypeValidator, JwtTimestampValidator and X509CertificateThumbprintValidator. Setting issuer-uri as well does not add an issuer check to it; KeyValueCondition then fails and Boot uses the issuer-uri decoder. Declare a JwtIssuerValidator bean to check iss.

Why does my JWT authentication have no ROLE_ authorities?

Because the default JwtGrantedAuthoritiesConverter reads scopes and prefixes them with SCOPE_. A token with only a roles claim got nothing but FACTOR_BEARER, and a token with "scope":"catalogue:read catalogue:write" got SCOPE_catalogue:read and SCOPE_catalogue:write. With authorities-claim-name=roles and authority-prefix=ROLE_, Boot 4.1.1 created a JwtAuthenticationConverter bean and the same request had ROLE_USER and FACTOR_BEARER.

What is the default clock skew for JWT expiration in Spring Security?

60 seconds. In Spring Security 7.1.1 the default JwtTimestampValidator has clockSkew = PT1M: tokens whose exp was 5, 30 and 59 seconds in the past answered 200, and 61 and 90 seconds answered 401 with Jwt expired at …. A JwtTimestampValidator bean with another Duration replaces the default in Boot's decoder; with Duration.ZERO, a token expired one second earlier was rejected.

Does Spring Security accept JWTs with alg none?

No. A token with the header {"alg":"none"} and an empty signature got 401 with error_description="Unsupported algorithm of none", and a token claiming HS256 with the public key text as HMAC secret got Signed JWT rejected: Another algorithm expected, or no matching key(s) found. The decoder Spring Boot builds from an RSA public key accepts RS256 only, as set by jws-algorithms.

Can a JWT be revoked before it expires?

Not with this design. After carol was disabled in the database, her token still answered 200 on GET /api/auth/me and 201 on POST /api/products, while a new login got 401. The decoder checks the signature and the claims and never reads the users table. Keep access tokens short-lived; refresh tokens, revocation lists and authorization servers belong to the Advanced course.

Why does the 401 still say WWW-Authenticate Basic after switching to JWT?

Because a custom entry point from the HTTP Basic setup still sets that header. Requests without a token reach the entry point from exceptionHandling, and invalid tokens reach the one on oauth2ResourceServer, which is a separate setting. Delegating to BearerTokenAuthenticationEntryPoint inside the handler, and setting the handler in both places, gave WWW-Authenticate: Bearer realm="catalogue", … together with the ProblemDetail body for both kinds of failure.

Conclusion

The login endpoint now issues a 15-minute RS256 token with iss, sub, iat, exp and roles, signed by a NimbusJwtEncoder built from an openssl key pair, and the API chain validates it with oauth2ResourceServer and the NimbusJwtDecoder that Spring Boot creates from public-key-location. A request no longer costs a SELECT and a BCrypt check for authentication: me took a 2.2 ms median against article 34's 55.8 ms. The token is Base64URL, readable by anyone, and protected by a signature that openssl verified with the public key; an edited payload, a foreign key, alg: none and an expired token each ended in a 401 whose WWW-Authenticate header names the reason, next to article 33's ProblemDetail body once the handler delegates to BearerTokenAuthenticationEntryPoint.

The defaults deserve a look before production: authorities come from scope with SCOPE_ until two properties map roles to ROLE_, expiry has a 60-second allowance, and neither iss nor a missing exp is checked until validator beans say so. The price of statelessness is revocation: a disabled user's token worked until its exp, which is why the lifetime stays short.

The next article builds on the ROLE_ authorities: authorization with role-based rules, @PreAuthorize, and the configuration of CORS and CSRF.

Related Posts

[Spring Boot Basics] Java Prerequisites for Spring Boot: OOP, Generics, Streams, Records and Annotations

The Java you need before Spring Boot 4.1.1: interfaces and polymorphism, List/Set/Map, generics and type erasure, lambdas and Stream, Optional, records as DTOs, and the one that matters most — custom annotations read back with reflection, exactly how @Component and @GetMapping work.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi on Spring Boot 4.1.1: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.

[Spring Boot Basics] Productivity Tools in Spring Boot: DevTools, Lombok and Actuator Basics

Spring Boot DevTools, Lombok and Actuator on Spring Boot 4.1.1: why developmentOnly keeps DevTools out of bootJar, the base and restart classloaders with a measured 0.185 s restart against a 1.488 s cold start, triggering restarts with ./gradlew -t classes, why a Gradle resource build restarts the app anyway, the property defaults DevTools applies and LiveReload deprecated in 4.1.0; what Lombok generates according to javap, @Value and @Builder against Java records with Jackson 3 and @Jacksonized, the @Data entity traps (StackOverflowError, a HashSet that loses an entity, LazyInitializationException, @Builder without a no-args constructor) and the safe subset; Actuator /actuator, /actuator/health with show-details and a 503 DOWN, exposure of /actuator/info with build, git, java and os info, why include=* is dangerous, and securing Actuator next to a securityMatcher("/api/**") chain.