Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

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-changeme

On 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:

RequestResponse
No Authorization header401, WWW-Authenticate: Bearer
Wrong password at login401, "detail":"Wrong username or password"
A damaged token401, error="invalid_token", Signed JWT rejected: Invalid signature
DELETE with Anna's token (role USER)403, error="insufficient_scope"
DELETE with an admin's token204

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 bits

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

Thanks for reading!

Donate to the site — we work hard for you.