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.
![]()
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:
| Request | Status | Requests | Min | Median | Max |
|---|---|---|---|---|---|
GET /api/auth/me with a bearer token | 200 | 30 | 1.7 ms | 2.2 ms | 3.5 ms |
GET /api/auth/me without a token | 401 | 30 | 0.5 ms | 0.6 ms | 0.8 ms |
POST /api/auth/login | 200 | 15 | 55.4 ms | 55.9 ms | 58.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.
Session cookie or token: where the login state lives
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:
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 GMTThe 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.

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.
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"eyJraWQiOiJDelhSOTJ3ZHVOdFU4ZFI3OVlvRUZleGFZTXlVbDhXZndpQTl0YlBHU3k0IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxMzUiLCJzdWIiOiJhbGljZSIsImV4cCI6MTc4OTM4MDA3OSwiaWF0IjoxNzg5Mzc5MTc5LCJyb2xlcyI6WyJVU0VSIl19
Jx-p7USeQya_he1mrvB4GEkqxYRUVonSiq-Hp0JzuJdby1cMgGvIFFNc31QCsYD6aeKaMFsz4U666GZnBWzJPDTo-gQdhHt-mpRsajiAnSPamV-bPpg0sRwMn_5PNUNMwFlm28YZv52-nX_xeX9yuri3cViXY0GZdCZ69DNJJF976Hm8wgaFy7qwfBQkEN2HuVgDhII_mBkB1WQ2UKPzs1xX0noToi1RuFiG_YqtE5m-YIAv8Z6Qbr4dWy5TjvFFMiixsxVM8ia9FhxHOacZRYP-UR2STpox-VusnoH1Q8GBsdcg0EMu_G7-Q3eMrJ72V59qJqFwWdMGQhzaOZ4l3QHeader, 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:
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 .{
"kid": "CzXR92wduNtU8dR79YoEFexaYMyUl8WfwiA9tbPGSy4",
"typ": "JWT",
"alg": "RS256"
}{
"iss": "http://localhost:8135",
"sub": "alice",
"exp": 1789380079,
"iat": 1789379179,
"roles": [
"USER"
]
}| Part | Member | Value in this token | Meaning |
|---|---|---|---|
| header | alg | RS256 | RSASSA-PKCS1-v1_5 signature with SHA-256 |
| header | typ | JWT | media type, written by NimbusJwtEncoder |
| header | kid | CzXR92…GSy4 | key id: the RFC 7638 thumbprint of the public key |
| payload | iss | http://localhost:8135 | issuer, one fixed value in this application |
| payload | sub | alice | subject: the username |
| payload | iat | 1789379179 | issued at, in seconds: 2026-09-14T09:46:19Z |
| payload | exp | 1789380079 | expires at, iat + 900: 2026-09-14T10:01:19Z |
| payload | roles | ["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:
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 | b64urlCzXR92wduNtU8dR79YoEFexaYMyUl8WfwiA9tbPGSy4The 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:
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 256
Verified OK256 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.

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
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'
}<dependencies>
<!-- the dependencies of article 34, plus: -->
<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-security-oauth2-resource-server-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Initializr's oauth2-resource-server id writes exactly these two lines. What the main starter brings:
./gradlew dependencies --configuration runtimeClasspath+--- 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-jwt10.9.1 parses, signs and verifies the tokens.spring-security-oauth2-josewraps it inJwtDecoder,JwtEncoderand their Nimbus implementations.spring-security-oauth2-resource-serverholdsBearerTokenAuthenticationFilter,JwtAuthenticationProviderandJwtAuthenticationConverter.spring-boot-security-oauth2-resource-serveris 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
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.pemgenpkey 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.
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-----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.pemis PKCS#8 (BEGIN PRIVATE KEY). Whoever holds it can create tokens this API accepts, as a later section does from the shell.public.pemis an X.509SubjectPublicKeyInfo(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
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem
app.jwt.private-key-location=classpath:certs/private.pem spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:certs/public.pem
app:
jwt:
private-key-location: classpath:certs/private.pemThe 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:
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:
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 = falseNimbus 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:
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();
}
}@Valueconverts aclasspath:location into a key.WebSecurityConfigurationinspring-security-config7.1.1 declares a staticconversionServicePostProcessor()bean, anRsaKeyConversionServicePostProcessor, which adds converters forRSAPublicKeyfrom X.509 PEM andRSAPrivateKeyfrom 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 andwithSecretKey; its builder offersalgorithm(...)andjwkPostProcessor(...). Older examples assemble anRSAKey, aJWKSetand anImmutableJWKSetby hand for theNimbusJwtEncoder(JWKSource)constructor, which still exists.- The
kidin the header is the thumbprint computed earlier, andtyp: JWTis a constant in the bytecode ofNimbusJwtEncoder.
Issuing a token from POST /api/auth/login
The response is a small record:
package com.example.demo.user;
public record TokenResponse(String accessToken, String tokenType, long expiresIn) {
}A service builds the claims and signs them:
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:
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:
return ResponseEntity.of(problem)
.header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"")
.header(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"catalogue\"")
.build();rolescomes from the authorities of the authenticated token withROLE_removed. A temporary log statement afterauthenticateprintedlogin authorities: [ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T09:46:20.296141Z]]; without the filter,FACTOR_PASSWORDwould end up in the claim.issueris one fixed value.JwtClaimAccessor.getIssuer()returns ajava.net.URLin 7.1.1, so the value is written as a URL.JwtEncoderParameters.from(claims)passes no header, and the encoder produced theRS256header withkidandtypshown earlier.LIFETIME.toSeconds()is theexpiresInof 900 seconds.
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8135/api/auth/loginHTTP/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:
@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:
DefaultSecurityFilterChain: Will secure Or [PathPattern [/api/**]] with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter, BearerTokenAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilterBearerTokenAuthenticationFiltersits whereBasicAuthenticationFilterwas. It readsAuthorization: Bearer, passes the token toJwtAuthenticationProvider, which calls theJwtDecoder, and stores the result inSecurityContextHolderfor this request.OAuth2ProtectedResourceMetadataFilteris new in the list. It serves RFC 9728 resource metadata at/.well-known/oauth-protected-resource, a path outside/api/**; in this application aGETon it was answered by the form-login chain with302to/login.STATELESSstays: 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/productswith the token and no CSRF token answered201with{"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:
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 GMTGET /api/auth/me with @AuthenticationPrincipal Jwt
The principal is no longer a UserDetails:
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()));
}curl -i -s -H "Authorization: Bearer $TOKEN" http://localhost:8135/api/auth/meHTTP/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:
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
meloading the profile. Authentication read no table: the token was checked with the public key and the claims. - The
Authenticationis aJwtAuthenticationToken, its name is thesubclaim, and its credentials are[PROTECTED]. - The principal is
JwtAuthenticationConverter$JwtAuthenticatedPrincipal.javapshows itextends org.springframework.security.oauth2.jwt.Jwt implements OAuth2AuthenticatedPrincipal, which is why@AuthenticationPrincipal Jwtreceives it. - The claims are converted:
expandiatprint asInstants, androlesis 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:
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:
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/meThe answer was 200, and the log:
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:
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_ spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:certs/public.pem
authorities-claim-name: roles
authority-prefix: ROLE_Boot 4.1.1 then creates the converter itself:
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_USERis whathasRole("USER")checks. Alice'sDELETE /api/products/1now reached article 33's rule and got its403with{"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_BEARERisFactorGrantedAuthority.BEARER_AUTHORITYinspring-security-core7.1.1, added for every request authenticated by a bearer token. TheFACTOR_PASSWORDof the login request is not in the token and does not come back.

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:
curl -i -s http://localhost:8135/api/auth/me
curl -i -s -H 'Authorization: Bearer not-a-jwt' http://localhost:8135/api/auth/meHTTP/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 GMTEach 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:
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
} .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler)) bearerEntryPoint.commenceaddsWWW-Authenticateand sets the status from the error, andwritereads that status back instead of assuming 401.setRealmName("catalogue")keeps the realm of articles 33 and 34.- The component is set twice, on
exceptionHandlingfor requests without a token and onoauth2ResourceServerfor tokens that fail.
The request without a token after a restart:
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
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/meHTTP/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:
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 tokenA 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.
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{"iss":"http://localhost:8135","sub":"alice","exp":1789380079,"iat":1789379179,"roles":["ADMIN"]}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:
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/meHTTP/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.
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/meHTTP/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:
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)"
doneexp = now - 5s -> 200
exp = now - 30s -> 200
exp = now - 59s -> 200
exp = now - 61s -> 401
exp = now - 90s -> 401A token that expired 59 seconds earlier still worked. The token 90 seconds past its exp, sent at 2026-09-14T09:46:19Z:
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:
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:
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 = falseOne 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:
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:
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=createcarol 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:
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:
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"(Update count: 1, 1 ms)
USERNAME | ENABLED
alice | TRUE
carol | FALSE
(2 rows, 2 ms)The same token, four seconds later:
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:
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 vs session cookie vs JWT bearer
| HTTP Basic (article 34) | Session cookie (form login) | JWT bearer (this article) | |
|---|---|---|---|
| Each request carries | Authorization: Basic with username and password, only Base64-encoded | Cookie: JSESSIONID=…, a random id | Authorization: Bearer …, signed claims readable by anyone |
| The server keeps | the users table; nothing about the login | an HttpSession with the SecurityContext per login | the public key and the validators; nothing per user |
| Work per request | SELECT and BCrypt: 55.8 ms median at cost 10 | session lookup | signature check: me in 2.2 ms median, its own SELECT included |
| Revocation | immediate: a disabled user's next request fails | immediate: invalidate the session | none before exp; a disabled user's token still answered 200 |
| CSRF exposure | none from curl or client code that sets the header; a browser may resend Basic credentials it has cached | yes: the browser attaches the cookie automatically, so CSRF protection must stay on | none 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.