๐Ÿ‡ฎ๐Ÿ‡ณ
๐Ÿ‡ฎ๐Ÿ‡ณ
Limited-Time Offer!Get 20% OFF on all live courses
Enroll Now
PrakalpanaLive online tech training
Java Ecosystemโฑ๏ธ 13 min read๐Ÿ“… Oct 1

Spring Security JWT Tutorial (2026): Secure a Spring Boot 3 REST API

PM
Priya Menonโ€ขBackend Tech Lead
๐Ÿ“‘ Contents (19 sections)

๐Ÿ“Œ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 token
  • Protected endpoints require Authorization: Bearer
  • Role-based access for admin endpoints
  • A refresh endpoint to get a new access token
  • 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-env
    app.jwt.access-ttl=15m
    app.jwt.refresh-ttl=7d

    When 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
    @EnableMethodSecurity
    public 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

    @Service
    public 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

    @Service
    public 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:

  • Make refresh tokens opaque random values, not JWTs, and store only a hash in the database with the user, expiry and a revoked flag
  • Rotate on every use: issue a new refresh token and invalidate the old one
  • If a revoked token is ever presented, treat it as theft and revoke the user's whole token family
  • Revoke refresh tokens on logout and password change
  • ๐Ÿ“Œ8. Method-Level Authorisation

    @Service
    public 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

  • Extending WebSecurityConfigurerAdapter โ€” it no longer exists; declare a SecurityFilterChain bean
  • Hard-coding the signing secret or using a short one
  • Long-lived access tokens with no refresh flow
  • Putting sensitive data in the JWT payload โ€” it is only Base64-encoded, not encrypted
  • Not validating issuer, audience and expiry
  • Storing plain-text or MD5 passwords โ€” use BCrypt or Argon2 through a PasswordEncoder
  • Forgetting the ROLE_ prefix when mixing 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 (the jti 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.

    PM

    Written by

    Priya Menon

    Backend Tech Lead

    ๐Ÿš€ Master Java Ecosystem

    Live online + 1-on-1 Spring Boot training ยท Join 5000+ developers