Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

JWT Login in Spring Boot 4 Without jjwt or a Custom Filter

Spring Security already ships everything you need. Here's the whole setup in four small classes. Search "Spring Boot JWT" and most tutorials give you the same recipe. Add the jjwt library. Write a OncePerRequestFilter

JWT Login in Spring Boot 4 Without jjwt or a Custom Filter

Spring Security already ships everything you need. Here's the whole setup in four small classes.

Search "Spring Boot JWT" and most tutorials give you the same recipe. Add the jjwt library. Write a OncePerRequestFilter. Pull the token out of the Authorization header by hand, parse it, catch the exceptions, and put the user into the SecurityContext yourself.

That works, but it leaves you owning security code that Spring Security already has, tested and maintained by its own team.

Spring Security has supported JWT for years through its OAuth2 Resource Server module. The name puts people off because it sounds like you need Keycloak or Auth0, yet the same module will verify tokens that your own app signs with a secret key.

In this post we'll build JWT login on Spring Boot 4 (Spring Security 7) with:

  • no jjwt or any other JWT library to add
  • no custom filter
  • roles from the token that work with @PreAuthorize("hasRole('ADMIN')")

What we're building

Four endpoints:

  • POST /api/auth/login checks a username and password, and returns a token
  • GET /api/products is public
  • POST /api/products needs a valid token
  • DELETE /api/products/{id} needs a token with the ADMIN role

Step 1: Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>

The third starter is the important one. It brings in Nimbus, the library Spring Security uses to sign and read JWTs, so there's nothing else to add.

On Spring Boot 3, the web starter is called spring-boot-starter-web and the last one is spring-boot-starter-oauth2-resource-server. The rest of this post works on Boot 3.3 and newer too.

Step 2: A secret key

We'll sign tokens with HS256, which uses one shared secret for signing and checking. The secret must be at least 32 bytes long, or Nimbus rejects it.

# application.yml
jwt:
  secret: ${JWT_SECRET}    # comes from an environment variable, never from git
  issuer: demo-api
  ttl-minutes: 60

Generate a random secret and start the app:

export JWT_SECRET=$(openssl rand -base64 48)
./mvnw spring-boot:run

Step 3: The encoder and decoder

import com.nimbusds.jose.jwk.source.ImmutableSecret;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.*;

@Configuration
public class JwtConfig {

    @Bean
    SecretKey jwtKey(@Value("${jwt.secret}") String secret) {
        if (secret.length() < 32) {
            throw new IllegalStateException("jwt.secret must be at least 32 characters");
        }
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey jwtKey) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(jwtKey));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey jwtKey, @Value("${jwt.issuer}") String issuer) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(jwtKey)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
        decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));
        return decoder;
    }
}

The JwtEncoder signs new tokens. The JwtDecoder checks incoming ones, and once a JwtDecoder bean exists, Spring Security uses it for every request that carries a bearer token.

By default the decoder checks the signature and the expiry time. createDefaultWithIssuer also rejects tokens whose iss claim isn't ours, so a token made for another system can't get in, even if someone reused the same secret.

Step 4: Creating tokens

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final String issuer;
    private final long ttlMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.issuer}") String issuer,
                        @Value("${jwt.ttl-minutes}") long ttlMinutes) {
        this.encoder = encoder;
        this.issuer = issuer;
        this.ttlMinutes = ttlMinutes;
    }

    public String issue(Authentication auth) {
        Instant now = Instant.now();
        List<String> roles = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer(issuer)
                .issuedAt(now)
                .expiresAt(now.plus(Duration.ofMinutes(ttlMinutes)))
                .subject(auth.getName())
                .claim("roles", roles)
                .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); // must match the key type
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long ttlSeconds() {
        return ttlMinutes * 60;
    }
}

Two of these lines are easy to get wrong:

  • claim("roles", roles) puts the user's roles, like ROLE_ADMIN, into the token. We'll read them back in Step 6.
  • JwsHeader.with(MacAlgorithm.HS256) tells the encoder to use our secret. Leave it out and the encoder looks for an RSA key, finds none, and fails with "Failed to select a JWK signing key".

Step 5: The login endpoint

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    public record LoginRequest(String username, String password) {}

    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    private final AuthenticationManager authManager;
    private final TokenService tokens;

    public AuthController(AuthenticationManager authManager, TokenService tokens) {
        this.authManager = authManager;
        this.tokens = tokens;
    }

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
        return new TokenResponse(tokens.issue(auth), "Bearer", tokens.ttlSeconds());
    }

    @ExceptionHandler(AuthenticationException.class)
    public ProblemDetail badLogin() {
        // same answer for a wrong username and a wrong password
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password");
    }
}

AuthenticationManager checks the password for us. If it's wrong, it throws, and the handler at the bottom turns that into a clean 401. Never tell the caller which part was wrong. "User not found" helps attackers guess valid usernames.

Step 6: The security config

In most tutorials, this is where the custom filter goes. Here it's the single oauth2ResourceServer(...) call.

@Configuration
@EnableMethodSecurity // turns on @PreAuthorize
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth
                .jwt(jwt -> jwt.jwtAuthenticationConverter(rolesFromToken())));
        return http.build();
    }

    // Read roles from our "roles" claim, e.g. ["ROLE_ADMIN"]
    private JwtAuthenticationConverter rolesFromToken() {
        JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
        roles.setAuthoritiesClaimName("roles");
        roles.setAuthorityPrefix("");
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(roles);
        return converter;
    }

    @Bean
    UserDetailsService users(PasswordEncoder encoder) {
        // demo users; in a real app, load them from your database
        return new InMemoryUserDetailsManager(
                User.withUsername("admin").password(encoder.encode("admin123")).roles("ADMIN").build(),
                User.withUsername("user").password(encoder.encode("user123")).roles("USER").build());
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }
}

What each part does:

  • csrf.disable() is safe here because the browser never sends our token automatically. It travels in the Authorization header, which an attacker's page can't add. If you ever move the token into a cookie, turn CSRF protection back on.
  • STATELESS stops Spring from creating an HTTP session, so the token is the only proof of who you are.
  • oauth2ResourceServer(... jwt(...)) replaces the custom filter. It reads the Authorization: Bearer header, checks the token with our JwtDecoder, and logs the user in for that request.
  • rolesFromToken() handles the part most people miss. By default, Spring reads permissions from a claim called scope and adds a SCOPE_ prefix. Our token has a roles claim instead, so without this converter every hasRole('ADMIN') check fails with a 403.

Step 7: Use it in a controller

@RestController
@RequestMapping("/api/products")
public class ProductController {

    public record ProductRequest(String name) {}

    @GetMapping
    public List<String> list() {
        return List.of("Mouse", "Keyboard");
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public String create(@RequestBody ProductRequest request, @AuthenticationPrincipal Jwt jwt) {
        return request.name() + " created by " + jwt.getSubject();
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @PreAuthorize("hasRole('ADMIN')")
    public void delete(@PathVariable Long id) {
        // delete it
    }
}

@AuthenticationPrincipal Jwt jwt hands you the decoded token, so jwt.getSubject() is the username and jwt.getClaim("roles") gives you the roles, without a database lookup.

Try it

# 1. Log in as a normal user
curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"user123"}'
# {"accessToken":"eyJhbGciOiJIUzI1NiJ9...","tokenType":"Bearer","expiresIn":3600}

TOKEN=paste-the-accessToken-here

# 2. Public endpoint: no token needed
curl -s localhost:8080/api/products                       # ["Mouse","Keyboard"]

# 3. Create without a token
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/api/products \
  -H "Content-Type: application/json" -d '{"name":"Monitor"}'          # 401

# 4. Create with the token
curl -s -X POST localhost:8080/api/products \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"name":"Monitor"}'          # Monitor created by user

# 5. Delete as a normal user
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/api/products/1 \
  -H "Authorization: Bearer $TOKEN"                                    # 403

Log in as admin / admin123 and the delete returns 204.

Paste your token into jwt.io and you'll see what's inside:

{
  "iss": "demo-api",
  "sub": "user",
  "roles": ["ROLE_USER"],
  "iat": 1794614400,
  "exp": 1794618000
}

A JWT is signed, not encrypted: without the secret nobody can change it, but anyone can read it. Never put passwords, emails or other personal data in the claims. (For the same reason, only paste test tokens into online decoders, never real ones from production.)

Debugging a 401

When a token is rejected, Spring Security tells you why, just not in the body. Look at the response headers:

curl -i localhost:8080/api/products -X POST -H "Authorization: Bearer $TOKEN"

The WWW-Authenticate header carries the reason, for example that the token has expired or the signature doesn't match. Check that header before you start adding log statements.

Five common mistakes

  1. A secret shorter than 32 bytes. Nimbus refuses it with "The secret length must be at least 256 bits". The check in JwtConfig catches this at startup instead of on the first login.
  2. Leaving out the HS256 JwsHeader. You get "Failed to select a JWK signing key" (see Step 4).
  3. Mixing up hasRole and hasAuthority. hasRole('ADMIN') looks for ROLE_ADMIN. If your claim holds plain ADMIN, use hasAuthority('ADMIN'), or every check returns 403.
  4. Forgetting the roles converter. Spring looks for a scope claim by default, so it ignores your roles claim and admins get 403 too.
  5. Long-lived tokens. You can't take a JWT back once it's issued, so keep access tokens short (15 to 60 minutes). If users need to stay logged in, add a refresh token that you store on the server, where you can revoke it.

When to switch to RS256

HS256 works well when one app both creates and checks tokens. Once other services need to verify your tokens, switch to RS256. You sign with a private key and share only the public key, so the other services can check tokens but can't create them. The decoder setup changes; the rest of this post stays the same.

Wrapping up

With one starter, an encoder and decoder, a small login endpoint and one line in the security config, you get JWT login with nothing extra to maintain.

A real app needs more around it: users loaded from a database, consistent JSON errors, tests for secured endpoints, and CORS for your frontend.

I put all of that into my Spring Boot 4 Cheat Sheet: 48 pages of code from one real app that compiles on Spring Boot 4.1, plus 55 interview answers, 22 common errors with their fixes, and the full source project. It's here: Spring Boot 4 Cheat Sheet + Interview Guide

If you have questions about the setup, leave a comment and I'll answer.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.