Securing REST API with Spring Security and JWT

1. Overview

REST APIs typically don’t use sessions. Each request must carry its own authentication information, and JWT (JSON Web Token) is the most common way to do this.

This guide walks you through securing an order management API using Spring Security 6 and Spring Boot 3. We’ll issue JWT when users log in, authenticate tokens on every request, and authorize based on roles. The final section covers automated tests for the key behaviors.

We won’t write a custom JWT filter. Instead, this guide uses OAuth2 Resource Server built into Spring Security. This approach requires less code and is more secure.

2. Maven Dependencies

You need the following dependencies in pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</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-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The oauth2-resource-server starter brings in the Nimbus JOSE + JWT library. This library handles token signing and signature verification. You don’t need to add jjwt or any other JWT library.

3. Configuring the Secret Key

For simplicity, we’ll sign tokens using the symmetric HS256 algorithm. The same secret key is used for both signing and verifying the signature.

jwt.secret=${JWT_SECRET:replace-this-with-a-secret-key-at-least-32-bytes-long}
jwt.expiration-minutes=15

The expression ${JWT_SECRET:...} reads the key from the JWT_SECRET environment variable. If this variable doesn’t exist, Spring uses the default value after the colon. The default value is only for development.

Note: HS256 requires a minimum key length of 256 bits, which is 32 bytes. If the key is shorter, Nimbus will throw KeyLengthException the first time you sign a token.

4. Configuring Spring Security

4.1. JwtEncoder, JwtDecoder, and Users

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

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

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

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

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

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

Here’s what each bean does:

  • SecretKey wraps the secret string into an HMAC-SHA256 key.
  • JwtEncoder uses this key to sign tokens when users log in.
  • JwtDecoder verifies the signature and expiration (exp) of tokens on each request. Resource Server uses this bean automatically.
  • InMemoryUserDetailsManager creates two sample users. In production, you’d replace this with an implementation that reads from a database.
  • AuthenticationManager verifies username and password at the login endpoint.

4.2. SecurityFilterChain

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

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

This bean is in the same SecurityConfig class. Here’s what each part does:

  • csrf.disable(): The API doesn’t use cookies for authentication, so CSRF attacks are not a concern.
  • STATELESS: Spring doesn’t create HttpSession. Each request is authenticated using the token.
  • authorizeHttpRequests: the login endpoint is public. /api/admin/** is restricted to the ADMIN role. All other endpoints require authentication.
  • oauth2ResourceServer(...jwt...): enables the filter to read the Authorization: Bearer <token> header and decode the token using JwtDecoder.

By default, Spring reads authorities from the scope claim and adds the SCOPE_ prefix. Since we want to use hasRole("ADMIN"), JwtAuthenticationConverter is reconfigured to read the roles claim. The prefix is removed because the token values are already in the form ROLE_ADMIN.

5. Issuing Tokens at Login

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

The token contains the following claims: issuer (iss), issued at (iat), expiration (exp), username (sub), and list of roles.

The line JwsHeader.with(MacAlgorithm.HS256) is required. Without it, NimbusJwtEncoder defaults to RS256. It then won’t find a suitable key and throws Failed to select a JWK signing key.

5.2. AuthController

@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;
    }

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

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate() verifies username and password via DaoAuthenticationProvider. If incorrect, the method throws BadCredentialsException. Spring Security translates this to HTTP 401. If correct, the controller generates a token and returns it to the client.

Try calling it with curl:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. Protected Endpoints

@RestController
@RequestMapping("/api")
public class OrderController {

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwt injects the verified token into the controller. You can get the username with jwt.getSubject(). To read other claims, use jwt.getClaim("roles").

Try calling with three different scenarios:

# No token
curl -i localhost:8080/api/orders

# alice with valid token
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# alice calling admin endpoint
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

The results show two different error codes. 401 means not authenticated: missing token, invalid signature, or expired token. 403 means authenticated but insufficient permissions.

7. Writing Tests

spring-security-test provides the jwt() post-processor. It mocks an authenticated JWT without needing to sign a real token. We combine it with an end-to-end test that performs actual login.

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"wrong\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

The required static imports are MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt, and AssertJ’s Assertions.assertThat.

The second test verifies the entire flow. It calls the login endpoint, retrieves a token, then uses that token to call the API. This test catches configuration errors in the encoder and decoder, such as forgetting JwsHeader or using a key that’s too short.

The last test uses jwt() to test authorization separately. Note that .authorities(...) overrides the converter, so you must pass the correct authority name ROLE_CUSTOMER.

8. Common Errors and When Not to Use This Approach

Common errors:

  • Key too short: keys under 32 bytes cause KeyLengthException with HS256.
  • Forgetting HS256 header: JwtEncoder reports Failed to select a JWK signing key.
  • Wrong authority prefix: with the default converter, authorities are in the form SCOPE_.... Then hasRole("ADMIN") always returns 403. Check that the claim and prefix match.
  • Hardcoding secrets in the repo: always read secrets from environment variables or a secret manager.
  • Putting sensitive data in the payload: JWT payload is only Base64URL encoded, not encrypted. Anyone with the token can read the content. Don’t put passwords, card numbers, or personal information in the token.

Note: JWTs can’t be revoked before expiration unless you add a supplementary mechanism like a blacklist. Keep access tokens short-lived (5–15 minutes). For longer sessions, use an additional refresh token stored on the server so it can be revoked.

When not to use this approach:

  • Server-rendered web applications, like Thymeleaf: sessions with cookies are simpler and more secure.
  • Need immediate logout or account lock effects: sessions or opaque tokens with introspection are better suited.
  • Multi-service systems: sharing one HMAC key across all services is risky. Instead, use a dedicated authorization server like Spring Authorization Server or Keycloak. The auth server signs tokens with RSA or EC, while services only need to configure spring.security.oauth2.resourceserver.jwt.issuer-uri to fetch the public key via JWKS.

9. Conclusion

This guide built a stateless REST API secured with JWT, covering these steps:

  • Use NimbusJwtEncoder and NimbusJwtDecoder with HS256 keys.
  • Issue tokens at the /api/auth/login endpoint via AuthenticationManager.
  • Let OAuth2 Resource Server automatically authenticate the Bearer header.
  • Map the roles claim to authorities so you can use hasRole.
  • Test 401, 403, and success flows with MockMvc and AssertJ.

From this foundation, you can replace InMemoryUserDetailsManager with database-backed users, add refresh tokens, or switch to asymmetric keys when the system scales to multiple services.

spring-bootspring-securityjwtrest-apijava