Skip to main content
Java · lesson 57 of 66
Open the Java page →
Java intermediate Lesson 57 of 66

Also 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

  1. Make the decoder reject a token the moment it expires, and also reject tokens whose iss is not shop-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.

  1. 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.

  1. 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.

Frequently Asked Questions

What is a JWT?
A JSON Web Token is three base64url-encoded parts joined by dots: a header naming the signing algorithm, a payload of claims such as sub (the user), exp (expiry) and roles, and a signature over the first two. The server verifies the signature with its key, so it can trust the claims without a database lookup or a session.
Is the JWT payload encrypted?
No. A signed JWT (JWS) is only encoded, so anyone holding the token can decode and read the payload. The signature prevents changes, not reading. Never put passwords or other secrets in claims, and send tokens only over HTTPS. Encrypted tokens (JWE) exist but are rarely needed for access tokens.
Do I need the jjwt library to use JWT with Spring Boot?
No. spring-boot-starter-oauth2-resource-server includes Nimbus JOSE + JWT and Spring Security's JwtEncoder and JwtDecoder. Calling oauth2ResourceServer(rs -> rs.jwt(...)) adds a filter that reads the Authorization: Bearer header, verifies the token and sets the authenticated user, so you write no custom filter.
Why is my expired JWT still accepted?
Spring Security's default JwtTimestampValidator allows 60 seconds of clock skew, so a token is accepted for up to a minute after its exp claim. That tolerates small clock differences between servers. Set a JwtTimestampValidator with Duration.ZERO, or a smaller skew, on the JwtDecoder if you need a strict cut-off.
How do I log a user out when using JWTs?
A stateless JWT stays valid until it expires, because the server keeps no session to delete. Keep access tokens short-lived, such as 15 minutes, and have the client discard them on logout. If you must revoke tokens immediately, keep a server-side denylist of token ids, which reintroduces some state.