Spring Security, JWT & OAuth2
From token generation to production-grade security filters — stateless auth, OAuth2 flows, filter chain internals, and 6 senior interview Q&As.
Why JWT? — The Problem with Sessions
Session-Based Auth
- Session state stored on server — stateful
- Doesn't scale horizontally
- Sticky sessions or shared Redis needed
- Hard in microservices — which service owns it?
- Memory pressure under high load
JWT-Based Auth
- Token stored on client — stateless
- Any instance can validate independently
- Scales horizontally with zero coordination
- Self-contained — user, roles, expiry in token
- Perfect for microservices architecture
Key insight: JWT shifts the burden of state from server memory to the token itself. Every service can validate by re-computing the signature — no network call required.
JWT Token Structure
{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "user@co.com",
"roles": ["ADMIN"],
"iat": 1710000000,
"exp": 1710003600
}
HMACSHA256(
b64(header) +
"." +
b64(payload),
SECRET_KEY
)
⚠️ Never put secrets in the payload. The payload is Base64-encoded, not encrypted. Anyone can decode it with atob() in a browser. Never store passwords, credit card numbers, or PII in JWT claims.
HS256 vs RS256 — When to use which
| Algorithm | Type | Key | Best for |
|---|---|---|---|
| HS256 | Symmetric | One shared secret | Monolith / single service |
| RS256 | Asymmetric | Private signs, public verifies | Microservices — share only public key |
JWT Authentication Flow
Maven Dependencies
<!-- Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JWT (JJWT) --> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.12.3</version></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.12.3</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.3</version><scope>runtime</scope></dependency> <!-- OAuth2 Resource Server --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>
Application Configuration
# JWT — generate secret with SecureRandom in production application: security: jwt: secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 expiration: 900000 # 15 minutes in ms refresh-token: expiration: 604800000 # 7 days in ms # OAuth2 Resource Server — only if using OAuth2 spring: security: oauth2: resourceserver: jwt: issuer-uri: https://accounts.google.com # Spring auto-fetches JWKS from /.well-known/openid-configuration
Access Token: 15 minutes — short-lived to minimize exposure if stolen. Refresh Token: 7 days — stored in DB to allow revocation. Client silently exchanges it for a new access token.
JwtService — Token Generation & Validation
@Service public class JwtService { @Value("${application.security.jwt.secret-key}") private String secretKey; @Value("${application.security.jwt.expiration}") private long jwtExpiration; // Extract username (subject) from token public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public <T> T extractClaim(String token, Function<Claims, T> resolver) { return resolver.apply(extractAllClaims(token)); } // Generate access token (no extra claims) public String generateToken(UserDetails userDetails) { return buildToken(new HashMap<>(), userDetails, jwtExpiration); } public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) { return buildToken(extraClaims, userDetails, jwtExpiration); } private String buildToken(Map<String, Object> extra, UserDetails ud, long exp) { return Jwts.builder() .claims(extra) .subject(ud.getUsername()) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + exp)) .signWith(getSignInKey()) .compact(); } // Two checks: username matches AND not expired public boolean isTokenValid(String token, UserDetails ud) { final String username = extractUsername(token); return (username.equals(ud.getUsername())) && !isTokenExpired(token); } private boolean isTokenExpired(String token) { return extractClaim(token, Claims::getExpiration).before(new Date()); } private Claims extractAllClaims(String token) { return Jwts.parser() .verifyWith(getSignInKey()) .build() .parseSignedClaims(token) .getPayload(); } private SecretKey getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } }
- ▸buildToken() — JJWT fluent builder:
subject= email/ID,issuedAt,expiration,signWithusing HMAC-SHA256. - ▸isTokenValid() — two guards: username in token must match UserDetails AND token must not be expired.
- ▸extractAllClaims() — signature verification happens here. If tampered,
JwtExceptionis thrown. - ▸getSignInKey() — decodes the Base64 hex key from config into a
SecretKeyfor HMAC signing.
JwtAuthFilter — Intercept Every Request
@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; // no token — skip silently } final String jwt = authHeader.substring(7); // strip "Bearer " final String userEmail = jwtService.extractUsername(jwt); // Only authenticate if not already set (idempotency guard) if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail); if (jwtService.isTokenValid(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, // credentials not needed post-auth userDetails.getAuthorities() // roles → GrantedAuthority ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } }
- ▸OncePerRequestFilter — Spring guarantees exactly one execution per request, even in async/forward chains.
- ▸substring(7) — strips the 7-character "Bearer " prefix to isolate the raw JWT string.
- ▸Idempotency guard:
getAuthentication() == null— prevents re-processing if another filter already authenticated the request. - ▸SecurityContextHolder.getContext().setAuthentication() — this single line makes Spring Security treat the request as authenticated for the rest of the filter chain.
SecurityFilterChain — Main Configuration
@Configuration @EnableWebSecurity @EnableMethodSecurity // enables @PreAuthorize @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; private final UserDetailsService userDetailsService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // JWT = stateless = no CSRF .sessionManagement(s -> s .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // no server sessions .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() // public .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .authenticationProvider(authenticationProvider()); return http.build(); } @Bean public AuthenticationProvider authenticationProvider() { var p = new DaoAuthenticationProvider(); p.setUserDetailsService(userDetailsService); p.setPasswordEncoder(passwordEncoder()); return p; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } }
- ▸csrf().disable() — safe because JWTs are sent in headers, not cookies. CSRF attacks require cookie-based auth to work.
- ▸STATELESS session policy — prevents Spring from creating HttpSession objects. Each request is independent.
- ▸addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) — ensures JWT validation runs before Spring's default login form filter.
- ▸DaoAuthenticationProvider — wires together UserDetailsService (user lookup) + BCryptPasswordEncoder (password check).
AuthController — Login & Register
@RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; private final JwtService jwtService; @PostMapping("/login") public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) { // 1. Throws AuthenticationException if credentials wrong authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(req.email(), req.password())); // 2. Load user and generate JWT var user = userDetailsService.loadUserByUsername(req.email()); var token = jwtService.generateToken(user); return ResponseEntity.ok(new AuthResponse(token)); } } // Java Records — clean, zero-boilerplate DTOs (Java 16+) public record LoginRequest(String email, String password) {} public record AuthResponse(String accessToken) {}
What is OAuth2?
OAuth2 is an authorization framework — it grants limited access to a user's resources without sharing passwords. Think "Login with Google": you never give your Google password to a third-party app. Google's Auth Server verifies you and issues a token. That's OAuth2.
Resource Owner
The user who owns the data and explicitly grants access permissions.
Auth Server
Issues tokens after verifying identity. Google, Okta, Keycloak, Azure AD.
Resource Server
Your API — validates the token and serves protected data to the client.
OAuth2 Grant Types — Which Flow, When?
| Grant Type | Use Case | Who Gets Token | Use in Production? |
|---|---|---|---|
| Authorization Code + PKCE | User logs in via browser (web/mobile apps) | Frontend app | ✅ Yes — standard |
| Client Credentials | Service-to-service, no user involved | Backend service | ✅ Yes — M2M |
| Implicit | Old SPAs (deprecated) | Browser directly | ❌ Avoid |
| Resource Owner Password | First-party trusted apps only | App | ⚠️ Avoid if possible |
Authorization Code + PKCE Flow
PKCE (Proof Key for Code Exchange): The client generates a random code_verifier, hashes it as code_challenge, sends the hash in step ②. In step ④, it sends the original verifier. Auth Server checks the hash matches — only the legitimate client can complete the flow. Essential for SPAs and mobile apps where a client secret can't be stored securely.
JWT vs OAuth2 — What's the Difference?
JWT — A Token Format
- Standard for representing claims as JSON
- Self-contained — all info in the token
- Signed with HS256 or RS256
- Stateless — no server lookup needed
- Can be used without OAuth2
OAuth2 — An Auth Protocol
- Framework for delegated authorization
- Defines how tokens are requested & issued
- Multiple grant types for different scenarios
- Includes scopes, consent, token refresh
- Often uses JWT as its token format
OAuth2 answers: "How do I get a token?" · JWT answers: "What format is the token?"
OAuth2 Resource Server — Spring Boot Config
@Configuration @EnableWebSecurity public class OAuth2SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .requestMatchers("/admin/**").hasAuthority("SCOPE_admin") .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 // ← replaces custom JwtAuthFilter .jwt(Customizer::withDefaults)); // auto-validates via JWKS endpoint return http.build(); } // Maps custom "roles" claim to Spring GrantedAuthority objects @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { var c = new JwtGrantedAuthoritiesConverter(); c.setAuthoritiesClaimName("roles"); c.setAuthorityPrefix("ROLE_"); var jc = new JwtAuthenticationConverter(); jc.setJwtGrantedAuthoritiesConverter(c); return jc; } }
- ▸oauth2ResourceServer().jwt() — Spring auto-fetches the JWKS (public keys) from the Auth Server's
/.well-known/openid-configurationendpoint and validates every incoming JWT. Zero manual token validation code. - ▸JwtGrantedAuthoritiesConverter — maps a custom
rolesclaim in your JWT to Spring'sGrantedAuthorityobjects, enabling@PreAuthorize("hasRole('ADMIN')"). - ▸With issuer-uri in yml, Spring auto-discovers the JWKS endpoint — you don't hardcode key URLs.
Class Traversal — JWT Login Request
POST /api/v1/auth/login — exact class sequence from HTTP request to JWT response
{ "email": "user@co.com", "password": "secret" }Hits Spring Security filter chain before any controller.
/api/v1/auth/** match .permitAll()? ✅ Yes — skips auth. JwtAuthFilter still runs but finds no Bearer token → passes through.UserDetailsService.loadUserByUsername() → fetches user from DB, validates password with BCryptPasswordEncoder. Throws BadCredentialsException if wrong.generateToken(userDetails) — builds JWT with subject, roles, iat, exp → signs with HMAC-SHA256 secret key → returns token string.Authorization: Bearer <token> on every subsequent API call.Class Traversal — Secure API Request
GET /api/v1/users (with Bearer token) — every protected request takes this path
Authorization: Bearer eyJhbGci...Enters filter chain — all registered filters run in sequence.
authHeader.startsWith("Bearer ") → ✅Extracts JWT:
jwt = authHeader.substring(7)Calls
jwtService.extractUsername(jwt) → gets user@co.comisTokenValid(jwt, userDetails) checks:① Username in token matches UserDetails ✅
② Token
exp claim > now ✅③ HMAC signature verified with secret key ✅
UsernamePasswordAuthenticationToken with userDetails + authorities (roles).Sets in
SecurityContextHolder — request is now authenticated.SecurityContextHolder → checks .anyRequest().authenticated() → ✅ passes.If role check fails → 403 Forbidden returned immediately.
@AuthenticationPrincipal to get the logged-in user. SecurityContext cleared after request — next request starts fresh.401 UnauthorizedisTokenExpired() returns true → auth not set → 401extractAllClaims() → JwtException → 401403 ForbiddenSenior Interview Q&A — 6 Questions
AuthenticationManager for auth, SecurityContext + roles/scopes for authz.exp claim passes. Short-lived access tokens (15 min) + long-lived refresh tokens (7 days) minimize exposure if stolen. On expiry, the client silently sends the refresh token to get a new access token without re-login. Store refresh tokens in a DB to allow revocation.JwtAuthFilter runs before UsernamePasswordAuthenticationFilter, extracts the Bearer token, validates it, and sets an Authentication object in SecurityContextHolder — making the user's identity available to every downstream filter and controller method.code_verifier, hashes it as code_challenge, and sends the hash with the authorization request. When exchanging the code for a token, it sends the original verifier — only the legitimate client can complete the flow. Essential for mobile and SPA apps where a client secret can't be stored securely.Client Credentials: service-to-service communication with no user involved. The backend service authenticates itself using client ID + secret. Used for microservice-to-microservice API calls.
Quick Reference
| Class / Annotation | Purpose |
|---|---|
| JwtService | Token generation (JJWT builder), claim extraction, signature validation |
| JwtAuthFilter | Extends OncePerRequestFilter — extracts Bearer token, validates, sets SecurityContextHolder |
| SecurityFilterChain | Central security config — CSRF, session policy, URL rules, filter order |
| DaoAuthenticationProvider | Wires UserDetailsService + PasswordEncoder for credential validation |
| SecurityContextHolder | Thread-local store for current request's Authentication object — cleared after request |
| OncePerRequestFilter | Base class guaranteeing exactly one filter execution per request |
| @EnableMethodSecurity | Enables @PreAuthorize("hasRole('ADMIN')") on controller methods |
| oauth2ResourceServer().jwt() | Auto-validates JWTs via JWKS endpoint — no manual filter needed |
| JwtGrantedAuthoritiesConverter | Maps custom JWT claim (e.g. "roles") to Spring GrantedAuthority objects |
| Token | Lifetime | Where stored | Purpose |
|---|---|---|---|
| Access Token | 15 min | Memory / header | Sent with every API call |
| Refresh Token | 7 days | HttpOnly cookie / DB | Exchange for new access token silently |
| ID Token (OIDC) | Short | App memory | User identity info (not for API auth) |