๐What We Will Build
Stateless JWT authentication for a Spring Boot 3 REST API:
POST /api/auth/login returns a short-lived access token and a refresh tokenAuthorization: Bearer We use Spring Security 6's OAuth2 Resource Server support with Nimbus JWT โ no hand-written JWT filter, no third-party token library, and no WebSecurityConfigurerAdapter (it was removed in Spring Security 6). If you have not built the API yet, start with our Spring Boot REST API tutorial.
๐1. Dependencies
<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>๐2. Signing Keys
For a single service, an HMAC secret of at least 256 bits is enough. Keep it out of source control โ load it from an environment variable or a secrets manager.
app.jwt.secret=change-me-to-a-long-random-256-bit-secret-from-envapp.jwt.access-ttl=15mapp.jwt.refresh-ttl=7dWhen several services verify tokens, switch to RSA or EC keys (or an identity provider such as Keycloak) so only the issuer holds the private key and others verify with the public key.
๐3. Security Configuration
@Configuration@EnableMethodSecuritypublic class SecurityConfig { @Value("${app.jwt.secret}") private String secret; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))) .build(); } private SecretKey key() { return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); } @Bean JwtEncoder jwtEncoder() { return new NimbusJwtEncoder(new ImmutableSecret<>(key())); } @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withSecretKey(key()).build(); } private JwtAuthenticationConverter jwtAuthConverter() { var authorities = new JwtGrantedAuthoritiesConverter(); authorities.setAuthoritiesClaimName("roles"); authorities.setAuthorityPrefix("ROLE_"); var converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(authorities); return converter; } @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } @Bean AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) { var provider = new DaoAuthenticationProvider(encoder); provider.setUserDetailsService(uds); return new ProviderManager(provider); }}CSRF is disabled because the API is stateless and reads tokens from the Authorization header, not cookies. If you store tokens in cookies, keep CSRF protection on. (On Spring Security versions before 6.3, construct DaoAuthenticationProvider without arguments and call setPasswordEncoder.)
๐4. Loading Users
@Servicepublic class AppUserDetailsService implements UserDetailsService { private final UserRepository users; public AppUserDetailsService(UserRepository users) { this.users = users; } @Override public UserDetails loadUserByUsername(String email) { AppUser u = users.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("User not found")); return User.withUsername(u.getEmail()) .password(u.getPasswordHash()) .roles(u.getRoles().toArray(String[]::new)) .build(); }}๐5. Issuing Tokens
@Servicepublic class TokenService { private final JwtEncoder encoder; public TokenService(JwtEncoder encoder) { this.encoder = encoder; } public String accessToken(Authentication auth) { Instant now = Instant.now(); List<String> roles = auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .map(a -> a.replaceFirst("^ROLE_", "")) .toList(); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("prakalpana-shop") .issuedAt(now) .expiresAt(now.plus(15, ChronoUnit.MINUTES)) .subject(auth.getName()) .claim("roles", roles) .build(); JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); }}๐6. Login and Refresh Endpoints
public record LoginRequest(@NotBlank String email, @NotBlank String password) {}public record TokenResponse(String accessToken, String refreshToken) {}@RestController@RequestMapping("/api/auth")public class AuthController { private final AuthenticationManager authManager; private final TokenService tokens; private final RefreshTokenService refreshTokens; public AuthController(AuthenticationManager authManager, TokenService tokens, RefreshTokenService refreshTokens) { this.authManager = authManager; this.tokens = tokens; this.refreshTokens = refreshTokens; } @PostMapping("/login") public TokenResponse login(@Valid @RequestBody LoginRequest req) { Authentication auth = authManager.authenticate( UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password())); return new TokenResponse(tokens.accessToken(auth), refreshTokens.issue(auth.getName())); } @PostMapping("/refresh") public TokenResponse refresh(@RequestBody Map<String, String> body) { Authentication auth = refreshTokens.rotate(body.get("refreshToken")); return new TokenResponse(tokens.accessToken(auth), refreshTokens.issue(auth.getName())); }}๐7. Refresh Tokens Done Right
Access tokens are short-lived (5โ15 minutes) because a JWT cannot be revoked before it expires. Refresh tokens let clients get new ones without logging in again:
๐8. Method-Level Authorisation
@Servicepublic class AdminService { @PreAuthorize("hasRole('ADMIN')") public void deleteUser(Long id) { /* ... */ } @PreAuthorize("#email == authentication.name") public Profile getProfile(String email) { /* ... */ }}Remember that method security works through proxies, so it does not apply to calls within the same class โ a classic interview question covered in our Spring Framework interview questions.
๐9. Testing Secured Endpoints
mvc.perform(get("/api/admin/users") .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))) .andExpect(status().isOk());mvc.perform(get("/api/admin/users")) .andExpect(status().isUnauthorized());The jwt() request post-processor from spring-security-test creates a mock token without a real login.
๐Common Mistakes
hasRole and hasAuthority๐Frequently Asked Questions
JWT or server-side sessions?
Sessions are simpler and instantly revocable, and remain a great choice for a single server-rendered web app. JWTs suit stateless APIs, mobile clients and microservices, where any instance or service can verify a token without a shared session store. Many systems use both: a session cookie for the web front end via a BFF, and JWTs between services.Where should a browser client store tokens?
Avoid localStorage for long-lived tokens, because any XSS can read it. The safest pattern is to keep tokens on the server side of a backend-for-frontend and give the browser an HttpOnly, Secure, SameSite session cookie. If the SPA must hold tokens, keep the access token in memory and the refresh token in an HttpOnly cookie, with CSRF protection enabled.Should I write my own JWT filter?
Usually not. Spring Security's resource server support already parses the Authorization header, validates the signature and expiry, and builds the Authentication. Custom filters are a frequent source of security bugs, such as forgetting to check expiry or accepting the "none" algorithm.How do I log a user out with JWT?
Delete the client's tokens and revoke the refresh token server-side. The access token stays valid until it expires โ which is why it should be short-lived. If you need instant revocation, keep a small deny-list of token IDs (thejti claim) until their expiry.When should I use an identity provider instead?
As soon as you have several services, social login, single sign-on or multi-factor authentication requirements. Keycloak, Okta, Auth0 or AWS Cognito issue the tokens, and your Spring Boot services simply act as resource servers configured with the provider's issuer URI.๐In a Microservices Setup
Validate tokens once at the API gateway and again in each service as a resource server, ideally with tokens issued by a central identity provider such as Keycloak or Okta using RSA keys. See microservices design patterns and our microservices course.
๐Learn It Live
Prakalpana's Spring Boot course covers Spring Security with JWT and OAuth2 hands-on, live online with an optional 1-on-1 track. Revise with Spring Boot interview questions, then WhatsApp or call +91 9243078181 for a free demo.