Java · lesson 57 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also part of: Cryptography
JWT Authentication in Spring Boot
Issue and verify JWTs in Spring Boot 3 with Spring Security's resource server: login endpoint, roles claim, forged and expired tokens, all curled.
By SkillByExample · Updated
With a JWT the server issues a signed token at login and the client sends it back in an
Authorization: Bearer header on every request; the server verifies the signature instead of
looking up a session. Spring Security already contains everything needed — this lesson uses
spring-boot-starter-oauth2-resource-server (Nimbus JOSE + JWT underneath) with an HMAC key, so
there is no hand-written filter. The Spring Boot 3.5 app runs with
java -jar target/jwt-0.0.1-SNAPSHOT.jar; every response is what curl received. Code blocks start
at the type declaration, with imports omitted. Read the Spring Security
lesson first.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
# a random 256-bit key, e.g. from `openssl rand -base64 32`; in production: jwt.secret=${JWT_SECRET}
jwt.secret=RtTqPmfudTx+aL6CyG4mUTIwRwK492z/yGPu2w/wHGo=
jwt.ttl=15m
Security configuration
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.jwtAuthenticationConverter(rolesFromToken())))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
SecretKey jwtKey(@Value("${jwt.secret}") String base64) {
return new SecretKeySpec(Base64.getDecoder().decode(base64), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) { // verifies signature and exp on every request
return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
}
private JwtAuthenticationConverter rolesFromToken() { // "roles" claim -> authorities
JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
UserDetailsService users(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password(encoder.encode("alice-pw")).roles("USER").build(),
User.withUsername("bob").password(encoder.encode("bob-pw")).roles("USER", "ADMIN").build());
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider); // checks username + password at login
}
}
oauth2ResourceServer(... jwt ...) installs a filter that reads the bearer token, hands it to the
JwtDecoder — which checks the HS256 signature and the expiry — and turns the verified claims into
an Authentication. The converter maps a roles claim straight to authorities; by default Spring
would read a scope claim and prefix each value with SCOPE_. The AuthenticationManager is only
for the login endpoint, which checks a username and password the classic way.
Issuing tokens
@Service
public class TokenService {
private final JwtEncoder encoder;
private final Duration ttl;
public TokenService(JwtEncoder encoder, @Value("${jwt.ttl}") Duration ttl) {
this.encoder = encoder;
this.ttl = ttl;
}
public String issue(Authentication user) {
Instant now = Instant.now();
List<String> roles = user.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("shop-api")
.subject(user.getName())
.issuedAt(now)
.expiresAt(now.plus(ttl))
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
public long ttlSeconds() { return ttl.toSeconds(); }
}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final TokenService tokens;
public AuthController(AuthenticationManager authManager, TokenService tokens) {
this.authManager = authManager;
this.tokens = tokens;
}
record LoginRequest(String username, String password) {}
record TokenResponse(String token, String type, long expiresIn) {}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest req) {
Authentication user = authManager.authenticate( // throws on bad credentials
UsernamePasswordAuthenticationToken.unauthenticated(req.username(), req.password()));
return new TokenResponse(tokens.issue(user), "Bearer", tokens.ttlSeconds());
}
}
$ curl -s -w '\n' localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice-pw"}'
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzaG9wLWFwaSIsInN1YiI6ImFsaWNlIiwiZXhwIjoxNzkxNDE0MTE4LCJpYXQiOjE3OTE0MTMyMTgsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.KbBhUOv5zUsWWQfJ_ueNMvNQSQHrgTuFeR6F5AVcFzA","type":"Bearer","expiresIn":900}
$ curl -si localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"guess"}' | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 401
WWW-Authenticate: Bearer
The token is three base64url parts separated by dots: header, payload, signature. A wrong password
makes authenticate throw a BadCredentialsException, which Spring Security turns into a 401 — the
controller needs no error handling. expiresIn is the 15-minute TTL in seconds.
What is inside a token
$ TOKEN=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice-pw"}' | jq -r .token)
$ jq -cR 'split(".")[0:2] | map(@base64d | fromjson)[]' <<< "$TOKEN"
{"alg":"HS256"}
{"iss":"shop-api","sub":"alice","exp":1791414119,"iat":1791413219,"roles":["ROLE_USER"]}
No key was needed to read it: the payload is encoded, not encrypted, so never put secrets in
claims. iat and exp are Unix timestamps, 900 seconds apart. What the key protects is the third
part, the signature over the first two.
Calling the API with the token
@RestController
public class ApiController {
record Me(String name, List<String> authorities, String issuer, String expiresAt) {}
@GetMapping("/api/me")
public Me me(Authentication auth, @AuthenticationPrincipal Jwt jwt) { // the verified token
List<String> authorities = auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
return new Me(auth.getName(), authorities, jwt.getClaimAsString("iss"), String.valueOf(jwt.getExpiresAt()));
}
@GetMapping("/api/admin/stats")
public String stats() { return "admin stats"; }
}
$ curl -s -w '\n' localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
{"name":"alice","authorities":["ROLE_USER"],"issuer":"shop-api","expiresAt":"2026-10-07T23:01:59Z"}
$ curl -si localhost:8080/api/me | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 401
WWW-Authenticate: Bearer
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/admin/stats -H "Authorization: Bearer $TOKEN"
403
$ BOB=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"bob","password":"bob-pw"}' | jq -r .token)
$ curl -s -w '\n' localhost:8080/api/admin/stats -H "Authorization: Bearer $BOB"
admin stats
The server looked nothing up for /api/me: the name and roles came from the verified token, and
@AuthenticationPrincipal Jwt exposes its raw claims. Without a token the response is 401 with
WWW-Authenticate: Bearer; with alice’s valid token the admin endpoint is 403, exactly as with
HTTP Basic — URL rules and @PreAuthorize work unchanged on top of JWT authentication.
Forged tokens
Since anyone can read the payload, can alice rewrite it to make herself an admin?
$ FORGED=$(echo "$TOKEN" | cut -d. -f1).$(echo -n '{"iss":"shop-api","sub":"alice","exp":1999999999,"roles":["ROLE_ADMIN"]}' | base64 -w0 | tr '+/' '-_' | tr -d '=').$(echo "$TOKEN" | cut -d. -f3)
$ curl -si localhost:8080/api/admin/stats -H "Authorization: Bearer $FORGED" | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 401
WWW-Authenticate: Bearer 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"
The original signature no longer matches the changed payload, and producing a new one requires the secret key. This is why the key must be long and random and never committed: whoever holds it can mint admin tokens. With several services verifying tokens, prefer an asymmetric key pair (RS256), so only the issuer holds the private key.
Expiry and clock skew
Restart with a two-second lifetime and wait it out:
$ java -jar target/jwt-0.0.1-SNAPSHOT.jar --jwt.ttl=2s &
$ TOKEN=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice-pw"}' | jq -r .token)
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
200
$ sleep 3
$ curl -si localhost:8080/api/me -H "Authorization: Bearer $TOKEN" | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 200
The token expired a second ago and was still accepted. The default JwtTimestampValidator allows
60 seconds of clock skew, so servers with slightly different clocks don’t reject fresh tokens.
That is fine for a 15-minute token; practice task 1 removes the grace period. A JWT cannot be
revoked before it expires — the server keeps no session to delete — which is why access tokens are
kept short-lived.
Practice
- Make the decoder reject a token the moment it expires, and also reject tokens whose
issis notshop-api.
Solution
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ZERO), // no 60-second grace period
new JwtIssuerValidator("shop-api"))); // reject tokens from other issuers
return decoder;
}
$ java -jar target/jwt-0.0.1-SNAPSHOT.jar --jwt.ttl=2s &
$ TOKEN=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice-pw"}' | jq -r .token)
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
200
$ sleep 3
$ curl -si localhost:8080/api/me -H "Authorization: Bearer $TOKEN" | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-10-07T22:50:31Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
setJwtValidator replaces the default validators, so the timestamp check has to be included
explicitly.
- Add
POST /api/auth/refresh, which exchanges a still-valid token for a fresh one with a later expiry. Without a token it must return 401.
Solution
@PostMapping("/refresh") // needs a valid token: not in permitAll
public TokenResponse refresh(Authentication current) {
return new TokenResponse(tokens.issue(current), "Bearer", tokens.ttlSeconds());
}
$ TOKEN=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"bob","password":"bob-pw"}' | jq -r .token)
$ sleep 1
$ NEW=$(curl -s -X POST localhost:8080/api/auth/refresh -H "Authorization: Bearer $TOKEN" | jq -r .token)
$ for t in "$TOKEN" "$NEW"; do jq -cR 'split(".")[1] | @base64d | fromjson' <<< "$t"; done
{"iss":"shop-api","sub":"bob","exp":1791414425,"iat":1791413525,"roles":["ROLE_ADMIN","ROLE_USER"]}
{"iss":"shop-api","sub":"bob","exp":1791414426,"iat":1791413526,"roles":["ROLE_ADMIN","ROLE_USER"]}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/auth/refresh
401
/api/auth/refresh is covered by anyRequest().authenticated(), so only a valid token reaches it,
and TokenService copies the roles from the current authentication. Production systems usually
use a separate long-lived, revocable refresh token instead, so a stolen access token cannot extend
itself forever.
- A teammate proposes adding the user’s password hash and home address to the token “to save a database lookup”. What do you tell them?
Solution
The jq decode above needed no key: every claim is readable by anyone who sees the token — browser
storage, logs, proxies. Put only what authorization needs (subject, roles, expiry) in it, and look up
personal data on the server when it is required.