API login with a JWT token
Right now an API client sends the name and password with every request. A mobile app would have to store the user's password, and that is bad. The usual approach is different: the password is sent once, and in return the client gets a token — a string it then attaches to requests in the Authorization: Bearer ... header. The most common format for such tokens is JWT (JSON Web Token).
What is inside a JWT
A token has three parts separated by dots: a header, the data and a signature. The data is plain JSON encoded in Base64: who the user is, what roles they have, until when the token is valid. The server computes the signature with a secret key. A token cannot be forged without the key, and it can be checked without a database or a session — the key is enough.
In Spring Security the OAuth2 Resource Server module checks tokens:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>The key and the beans
A secret key for the HS256 algorithm must be at least 32 bytes long. We put it into the settings and also say where the roles are in the token:
# src/main/resources/application.yaml
spring:
security:
oauth2:
resourceserver:
jwt:
authorities-claim-name: roles
authority-prefix: ""
blog:
jwt:
secret: changeme-changeme-changeme-changemeOn a server the key comes from the BLOG_JWT_SECRET environment variable. In SecurityConfig we add the key, an encoder to issue tokens, a decoder to check them and an AuthenticationManager to check passwords:
// src/main/java/com/example/blog/SecurityConfig.java
@Bean
public SecretKey jwtKey(@Value("${blog.jwt.secret}") String secret) {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder(SecretKey jwtKey) {
return NimbusJwtEncoder.withSecretKey(jwtKey).build();
}
@Bean
public JwtDecoder jwtDecoder(SecretKey jwtKey) {
return NimbusJwtDecoder.withSecretKey(jwtKey).build();
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}AuthenticationManager checks the name and password through our BlogUserDetailsService — the same way the login form did.
Issuing a token
// src/main/java/com/example/blog/TokenService.java
@Service
public class TokenService {
private static final Duration LIFETIME = Duration.ofHours(1);
private final JwtEncoder encoder;
private final Clock clock;
public TokenService(JwtEncoder encoder, Clock clock) {
this.encoder = encoder;
this.clock = clock;
}
public TokenResponse issue(Authentication user) {
Instant now = Instant.now(clock);
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(name -> name.startsWith("ROLE_"))
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("blog")
.subject(user.getName())
.issuedAt(now)
.expiresAt(now.plus(LIFETIME))
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
String token = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
return new TokenResponse(token, LIFETIME.toSeconds());
}
}subject is the user name, roles holds roles such as ROLE_USER, expiresAt is the lifetime: after an hour the token is no longer accepted. The request and response records are simple:
// src/main/java/com/example/blog/LoginRequest.java
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}// src/main/java/com/example/blog/TokenResponse.java
public record TokenResponse(String token, long expiresIn) {
}// src/main/java/com/example/blog/AuthController.java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
Authentication user = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
return tokenService.issue(user);
}
}With a wrong password authenticate throws an AuthenticationException. We turn it into a 401 response in ApiExceptionHandler:
// src/main/java/com/example/blog/ApiExceptionHandler.java
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleLogin(AuthenticationException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Wrong username or password");
}The API chain accepts tokens
In apiSecurity we replace the httpBasic(...) line with a token check and open the login address:
// src/main/java/com/example/blog/SecurityConfig.java
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users", "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))Spring Security takes the token from the header, checks the signature and the expiry, and reads the roles from roles — we set that in the settings. The user is no longer a UserDetails, so in the controllers we write Principal principal and principal.getName() instead of @AuthenticationPrincipal UserDetails user. This works with both the token and the login form.
Checking it
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"anna","password":"changeme"}'{"token":"eyJraWQiOiJOWkFjMWJIMzRCWGFu...","expiresIn":3600}Copy the token into a shell variable TOKEN=eyJraWQi... and create a post:
curl -X POST http://localhost:8080/api/posts -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"title":"Hello, JWT","body":"A post sent with a token"}'{"id":1,"title":"Hello, JWT","body":"A post sent with a token","author":"anna","createdAt":"2026-10-04T10:49:22.976947400Z"}The middle part of the token decodes with echo $TOKEN | cut -d. -f2 | base64 -d and shows {"iss":"blog","sub":"anna","exp":1791114549,"iat":1791110949,"roles":["ROLE_USER"]}. The other cases:
| Request | Response |
|---|---|
No Authorization header | 401, WWW-Authenticate: Bearer |
| Wrong password at login | 401, "detail":"Wrong username or password" |
| A damaged token | 401, error="invalid_token", Signed JWT rejected: Invalid signature |
DELETE with Anna's token (role USER) | 403, error="insufficient_scope" |
DELETE with an admin's token | 204 |
The API no longer accepts a password through -u anna:changeme — only a token.
Common mistake
A short secret, such as changeme. The application does not start:
Factory method 'jwtEncoder' threw exception with message: com.nimbusds.jose.KeyLengthException: The secret length must be at least 256 bits256 bits is 32 bytes. For a real server generate a random key, for example with openssl rand -base64 32, and pass it through BLOG_JWT_SECRET instead of keeping it in Git.