🔐 Complete Guide

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.

Spring Boot 3.2.x Java 17 Spring Security 6.x JJWT 0.12.x OAuth2 PKCE
01

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.

02

JWT Token Structure

Part 1
Header
{
 "alg": "HS256",
 "typ": "JWT"
}
Algorithm + Type
Part 2
Payload
{
 "sub": "user@co.com",
 "roles": ["ADMIN"],
 "iat": 1710000000,
 "exp": 1710003600
}
Claims (NOT encrypted)
Part 3
Signature
HMACSHA256(
 b64(header) +
 "." +
 b64(payload),
 SECRET_KEY
)
Tamper detection
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 eyJzdWIiOiJ1c2VyQGNvLmNvbSIsInJvbGVzIjpbIkFETUlOIl0sImV4cCI6MTcxMDAwMzYwMH0 SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
■ Header ■ Payload ■ Signature Separated by dots ( . )

⚠️ 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

AlgorithmTypeKeyBest for
HS256SymmetricOne shared secretMonolith / single service
RS256AsymmetricPrivate signs, public verifiesMicroservices — share only public key
03

JWT Authentication Flow

CLIENT Browser / Mobile App AUTH SERVICE Validates creds, generates JWT JWT FILTER Validates token, sets SecurityCtx CONTROLLER Business logic, returns data DATABASE users / roles POST /login credentials JWT Token GET /api/data Authorization: Bearer <token> authorized
① POST /login ② JWT issued ③ API call with Bearer token ④ Validated → authorized
04

Maven Dependencies

pom.xml XML
<!-- 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>
05

Application Configuration

application.yml YAML
# 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.

06

JwtService — Token Generation & Validation

JwtService.java Java 17
@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);
  }
}
📖 Key points
  • buildToken() — JJWT fluent builder: subject = email/ID, issuedAt, expiration, signWith using HMAC-SHA256.
  • isTokenValid() — two guards: username in token must match UserDetails AND token must not be expired.
  • extractAllClaims() — signature verification happens here. If tampered, JwtException is thrown.
  • getSignInKey() — decodes the Base64 hex key from config into a SecretKey for HMAC signing.
07

JwtAuthFilter — Intercept Every Request

JwtAuthFilter.java Java 17
@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);
  }
}
📖 Key lines
  • 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.
08

SecurityFilterChain — Main Configuration

SecurityConfig.java Java 17
@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(); }
}
📖 Key decisions
  • 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).
09

AuthController — Login & Register

AuthController.java Java 17
@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) {}
10

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.

11

OAuth2 Grant Types — Which Flow, When?

Grant TypeUse CaseWho Gets TokenUse 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
12

Authorization Code + PKCE Flow

USER Browser / App CLIENT APP Your Spring Boot App AUTH SERVER Google / Keycloak / Okta RESOURCE SERVER Your API ① Login click ② Redirect + code_challenge ③ Auth Code ④ code + code_verifier ⑤ Access Token + Refresh Token ⑥ API call with Bearer Token

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.

13

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?"

14

OAuth2 Resource Server — Spring Boot Config

SecurityConfig.java (OAuth2 variant) Java 17
@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;
  }
}
📖 Key insight
  • oauth2ResourceServer().jwt() — Spring auto-fetches the JWKS (public keys) from the Auth Server's /.well-known/openid-configuration endpoint and validates every incoming JWT. Zero manual token validation code.
  • JwtGrantedAuthoritiesConverter — maps a custom roles claim in your JWT to Spring's GrantedAuthority objects, enabling @PreAuthorize("hasRole('ADMIN')").
  • With issuer-uri in yml, Spring auto-discovers the JWKS endpoint — you don't hardcode key URLs.
15

Class Traversal — JWT Login Request

POST /api/v1/auth/login — exact class sequence from HTTP request to JWT response

CLIENT
HTTP Request
POST /auth/login
{ "email": "user@co.com", "password": "secret" }
Hits Spring Security filter chain before any controller.
SPRING SECURITY
SecurityFilterChain
SecurityConfig.java
Checks: does /api/v1/auth/** match .permitAll()? ✅ Yes — skips auth. JwtAuthFilter still runs but finds no Bearer token → passes through.
AUTH MANAGER
DaoAuthProvider
SecurityConfig.java
Calls UserDetailsService.loadUserByUsername() → fetches user from DB, validates password with BCryptPasswordEncoder. Throws BadCredentialsException if wrong.
SERVICE
JwtService
JwtService.java
generateToken(userDetails) — builds JWT with subject, roles, iat, exp → signs with HMAC-SHA256 secret key → returns token string.
RESPONSE
200 OK
{ accessToken: "..." }
Client stores the JWT (localStorage / memory) and sends it as Authorization: Bearer <token> on every subsequent API call.
📦 Class chain — Login
HTTP Request SecurityFilterChain JwtAuthFilter (skip) AuthController DaoAuthProvider UserDetailsService JwtService 200 + JWT
16

Class Traversal — Secure API Request

GET /api/v1/users (with Bearer token) — every protected request takes this path

CLIENT
HTTP Request
GET /api/v1/users
Header: Authorization: Bearer eyJhbGci...
Enters filter chain — all registered filters run in sequence.
FILTER — STEP 1
JwtAuthFilter
Extract token
authHeader.startsWith("Bearer ") → ✅
Extracts JWT: jwt = authHeader.substring(7)
Calls jwtService.extractUsername(jwt) → gets user@co.com
SERVICE
JwtService
Validate token
isTokenValid(jwt, userDetails) checks:
① Username in token matches UserDetails ✅
② Token exp claim > now ✅
③ HMAC signature verified with secret key ✅
FILTER — STEP 2
JwtAuthFilter
Set SecurityContext
Creates UsernamePasswordAuthenticationToken with userDetails + authorities (roles).
Sets in SecurityContextHolder — request is now authenticated.
SPRING SECURITY
AuthorizationFilter
Role check
Reads SecurityContextHolder → checks .anyRequest().authenticated() → ✅ passes.
If role check fails → 403 Forbidden returned immediately.
RESPONSE
200 OK
Protected data
Controller executes business logic. Can use @AuthenticationPrincipal to get the logged-in user. SecurityContext cleared after request — next request starts fresh.
📦 Class chain — Secure API
HTTP + Bearer JwtAuthFilter JwtService.isTokenValid() SecurityContextHolder.setAuth() AuthorizationFilter Controller 200 + Data
⚠️ Failure Paths
No token sent
JwtAuthFilter skips → AuthorizationFilter finds no auth → 401 Unauthorized
Token expired
isTokenExpired() returns true → auth not set → 401
Token tampered
Signature verification fails in extractAllClaims() → JwtException → 401
Wrong role
Auth set but role check fails in AuthorizationFilter → 403 Forbidden
17

Senior Interview Q&A — 6 Questions

Q1: What is the difference between Authentication and Authorization?
Authentication verifies who you are (login with credentials). Authorization determines what you can do (roles, permissions). Spring Security handles both — AuthenticationManager for auth, SecurityContext + roles/scopes for authz.
Q2: Why should JWTs be short-lived? How do you handle expiry?
JWTs can't be revoked once issued — they're valid until the 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.
Q3: How does Spring Security's filter chain work?
Every HTTP request passes through a chain of security filters before reaching your controller. The 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.
Q4: What is PKCE and why is it needed?
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The client generates a random 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.
Q5: JWT vs OAuth2 — what's the actual difference?
JWT is a token format — a standard for encoding claims as a signed JSON object. OAuth2 is an authorization protocol — a framework that defines how tokens are requested and issued. OAuth2 often uses JWT as its token format, but they're independent: you can use JWT without OAuth2 (custom auth), or OAuth2 with opaque tokens. JWT answers "what format is the token?" OAuth2 answers "how do I get it?"
Q6: Authorization Code vs Client Credentials — when to use each?
Authorization Code + PKCE: a user is involved. A third-party app requests permission to act on the user's behalf. The user authenticates at the Auth Server and grants consent. Used in web/mobile apps.

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.
18

Quick Reference

Class / AnnotationPurpose
JwtServiceToken generation (JJWT builder), claim extraction, signature validation
JwtAuthFilterExtends OncePerRequestFilter — extracts Bearer token, validates, sets SecurityContextHolder
SecurityFilterChainCentral security config — CSRF, session policy, URL rules, filter order
DaoAuthenticationProviderWires UserDetailsService + PasswordEncoder for credential validation
SecurityContextHolderThread-local store for current request's Authentication object — cleared after request
OncePerRequestFilterBase class guaranteeing exactly one filter execution per request
@EnableMethodSecurityEnables @PreAuthorize("hasRole('ADMIN')") on controller methods
oauth2ResourceServer().jwt()Auto-validates JWTs via JWKS endpoint — no manual filter needed
JwtGrantedAuthoritiesConverterMaps custom JWT claim (e.g. "roles") to Spring GrantedAuthority objects
TokenLifetimeWhere storedPurpose
Access Token15 minMemory / headerSent with every API call
Refresh Token7 daysHttpOnly cookie / DBExchange for new access token silently
ID Token (OIDC)ShortApp memoryUser identity info (not for API auth)