Spring Boot/JWT authentication — stateless auth, signing, refresh token
~26 phútSpring Security cơ bảnMiễn phí

JWT authentication — stateless auth, signing, refresh token

JWT structure (header.payload.signature), HS256 vs RS256, signing/validation, Spring Security oauth2ResourceServer, custom JwtAuthenticationConverter, refresh token pattern, token revocation, security pitfalls (algorithm none, key leak).

Bài 03 dùng session-based auth. Bài này refactor sang JWT (JSON Web Token) — stateless, scalable, mobile/SPA-friendly. Đây là pattern auth chính cho REST API 2026.

JWT là tradeoff: stateless ưu, nhưng revocation khó. Hiểu trade-off rồi, design auth pipeline production-ready.

1. JWT structure

JWT là string 3 phần ngăn cách .:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQG9saHViLnZuIiwicm9sZXMiOlsiVVNFUiJdLCJpYXQiOjE3MTM3ODg4MDAsImV4cCI6MTcxMzc5MjQwMH0.QmJC...

3 phần (decoded base64url):

PartContentExample
HeaderAlgorithm + token type{"alg":"HS256","typ":"JWT"}
PayloadClaims (subject, roles, expiry){"sub":"[email protected]","roles":["USER"],"iat":1713788800,"exp":1713792400}
SignatureHMAC/RSA signature of header.payloadbinary signature
flowchart LR
    H[Header<br/>base64url]
    P[Payload<br/>base64url]
    S[HMAC SHA256<br/>secret]
    JWT["header.payload.signature"]

    H --> JWT
    P --> JWT
    S --> JWT

Decode JWT: copy token, paste vào https://jwt.io — show 3 parts. Payload là plaintext base64 — không encrypt, anyone đọc được.

1.1 Standard claims (RFC 7519)

{
  "iss": "olhub.org",                  // issuer
  "sub": "[email protected]",              // subject (user identifier)
  "aud": "olhub-api",                  // audience
  "exp": 1713792400,                   // expiration (Unix timestamp)
  "nbf": 1713788800,                   // not before
  "iat": 1713788800,                   // issued at
  "jti": "abc-123-uuid",               // unique JWT ID (for revocation)

  "roles": ["USER", "ADMIN"],          // custom claim
  "tenantId": "acme-corp",             // custom claim
  "name": "Alice"                      // custom claim
}

exp mandatory production. Without expiry → token lost = permanent breach.

2. HMAC vs RSA signing

AlgorithmKey typeUse case
HS256Symmetric (1 secret)Issuer = validator (same app)
RS256Asymmetric (private + public key)Issuer ≠ validator (microservices, IdP)

2.1 HS256 — symmetric

String secret = "your-256-bit-secret-key-must-be-long-enough";
Algorithm algorithm = Algorithm.HMAC256(secret);

String token = JWT.create()
    .withSubject("[email protected]")
    .withClaim("roles", List.of("USER"))
    .withIssuedAt(new Date())
    .withExpiresAt(new Date(System.currentTimeMillis() + 3600_000))
    .sign(algorithm);

// Validate
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT decoded = verifier.verify(token);

Pros: simple, fast. Cons: secret share giữa issuer + validator. Leak = full breach.

Use case: monolith app — issuer + API server cùng là 1 app.

2.2 RS256 — asymmetric

KeyPair keyPair = ...;     // RSA 2048-bit

// Issuer: sign với private key
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate());
String token = JWT.create()...sign(algorithm);

// Validator: verify với public key (chỉ cần public key)
Algorithm verifyAlgo = Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), null);
JWTVerifier verifier = JWT.require(verifyAlgo).build();
DecodedJWT decoded = verifier.verify(token);

Pros: validator chỉ cần public key — issuer hold private. Better separation. Cons: slower (~10x), bigger key, more complex.

Use case: microservices — auth service issue token, API services validate. Auth service hold private key, API services fetch public key (JWK).

3. Spring Security JWT — oauth2ResourceServer

Boot 3 dùng oauth2ResourceServer cho JWT validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
            );

        return http.build();
    }
}

oauth2ResourceServer.jwt() setup:

  • BearerTokenAuthenticationFilter parse Authorization: Bearer ... header.
  • JwtAuthenticationProvider validate token (signature + expiry).
  • JwtAuthenticationConverter build Authentication from JWT claims.

3.1 Configure JWT decoder

3 cách config:

Option 1 — JWK Set URL (OIDC standard):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.olhub.org/.well-known/jwks.json

Validator fetch public keys từ URL. Auto-rotate khi issuer rotate keys.

Option 2 — Issuer URI (OIDC discovery):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.olhub.org

Validator fetch /.well-known/openid-configuration → discover JWKS URI + issuer.

Option 3 — Public key (for testing or simple HS256):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:public.pem

Option 4 — Custom JwtDecoder bean:

@Bean
public JwtDecoder jwtDecoder() {
    SecretKey key = Keys.hmacShaKeyFor("your-256-bit-secret".getBytes());
    return NimbusJwtDecoder.withSecretKey(key).build();
}

Custom decoder cho HS256.

3.2 JwtAuthenticationConverter — extract roles

Default Spring extract roles from scope claim với prefix SCOPE_. Override cho custom claim:

@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
    JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
    authoritiesConverter.setAuthoritiesClaimName("roles");      // custom claim name
    authoritiesConverter.setAuthorityPrefix("ROLE_");           // prefix matching @hasRole

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
    converter.setPrincipalClaimName("sub");
    return converter;
}

JWT có "roles": ["USER", "ADMIN"] → authority ROLE_USER, ROLE_ADMIN. hasRole("ADMIN") work.

4. Issue JWT — login endpoint

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtIssuer jwtIssuer;

    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest req) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.email(), req.password())
        );

        UserDetails user = (UserDetails) auth.getPrincipal();
        String accessToken = jwtIssuer.issueAccessToken(user);
        String refreshToken = jwtIssuer.issueRefreshToken(user);

        return new TokenResponse(accessToken, refreshToken, "Bearer", 3600L);
    }
}

public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {}
public record TokenResponse(String accessToken, String refreshToken, String tokenType, Long expiresIn) {}
@Service
@RequiredArgsConstructor
public class JwtIssuer {

    private final JwtEncoder encoder;          // Spring Security 5.7+ JwtEncoder

    public String issueAccessToken(UserDetails user) {
        Instant now = Instant.now();
        List<String> roles = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .map(a -> a.replaceFirst("^ROLE_", ""))
            .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("olhub.org")
            .subject(user.getUsername())
            .audience(List.of("olhub-api"))
            .issuedAt(now)
            .expiresAt(now.plus(Duration.ofHours(1)))
            .id(UUID.randomUUID().toString())
            .claim("roles", roles)
            .build();

        return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }

    public String issueRefreshToken(UserDetails user) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("olhub.org")
            .subject(user.getUsername())
            .issuedAt(now)
            .expiresAt(now.plus(Duration.ofDays(30)))
            .id(UUID.randomUUID().toString())
            .claim("type", "refresh")
            .build();

        return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}
@Configuration
public class JwtConfig {

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

    @Bean
    public JwtEncoder jwtEncoder() {
        SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
        JWKSource<SecurityContext> jwkSource = (jwkSelector, context) ->
            jwkSelector.select(new JWKSet(new OctetSequenceKey.Builder(key).build()));
        return new NimbusJwtEncoder(jwkSource);
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
        return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
    }
}

5. Refresh token pattern

Access token short (1h), refresh token long (30d). Refresh khi access expire:

sequenceDiagram
    participant Client
    participant API as TaskFlow API
    participant Auth as Auth Service

    Note over Client: Login
    Client->>Auth: POST /auth/login
    Auth-->>Client: { accessToken (1h), refreshToken (30d) }

    Note over Client: Use API
    Client->>API: GET /projects (Authorization: Bearer access)
    API-->>Client: 200 OK

    Note over Client: After 1h, access expired
    Client->>API: GET /projects (expired token)
    API-->>Client: 401 (token expired)

    Note over Client: Refresh
    Client->>Auth: POST /auth/refresh (refreshToken)
    Auth->>Auth: Validate refresh token
    Auth-->>Client: { new accessToken, new refreshToken }

    Client->>API: GET /projects (new access)
    API-->>Client: 200 OK
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest req) {
    Jwt jwt = refreshTokenDecoder.decode(req.refreshToken());

    if (!"refresh".equals(jwt.getClaim("type"))) {
        throw new InvalidTokenException("Not a refresh token");
    }

    String username = jwt.getSubject();
    UserDetails user = userDetailsService.loadUserByUsername(username);

    String newAccessToken = jwtIssuer.issueAccessToken(user);
    String newRefreshToken = jwtIssuer.issueRefreshToken(user);

    // Optionally: revoke old refresh token (sliding expiration)
    refreshTokenStore.revoke(jwt.getId());

    return new TokenResponse(newAccessToken, newRefreshToken, "Bearer", 3600L);
}

6. Token revocation — logout / kill switch

JWT stateless = problem khi cần revoke (logout, password change, account compromise).

3 strategy:

6.1 Short expiry only (simplest)

Access token 5-15 min. Khi cần revoke, wait expiry. Refresh token revoke via store (section 6.2).

Tradeoff: not real-time. Window 15 min user vẫn access.

6.2 Token denylist (Redis)

@Service
public class TokenDenylistService {

    private final RedisTemplate<String, String> redis;

    public void revoke(String jti, Duration remainingTtl) {
        redis.opsForValue().set("revoked:" + jti, "true", remainingTtl);
    }

    public boolean isRevoked(String jti) {
        return Boolean.TRUE.equals(redis.hasKey("revoked:" + jti));
    }
}

@Component
public class JwtRevocationFilter extends OncePerRequestFilter {

    private final TokenDenylistService denylist;

    protected void doFilterInternal(...) {
        String token = extractToken(request);
        if (token != null) {
            Jwt jwt = jwtDecoder.decode(token);
            if (denylist.isRevoked(jwt.getId())) {
                response.setStatus(401);
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

Revoke: store JTI in Redis với TTL = remaining expiry. Filter check.

Tradeoff: stateful — defeat JWT stateless purpose somewhat. Acceptable cho selective revocation.

6.3 Refresh token rotation + database

@Entity
public class RefreshToken {
    @Id String id;          // JTI
    String username;
    Instant issuedAt;
    Instant expiresAt;
    boolean revoked;
}

// On refresh:
// 1. Verify refresh token in DB, not revoked
// 2. Mark old refresh token revoked
// 3. Issue new access + new refresh
// 4. Save new refresh to DB

// On logout:
// 1. Mark current refresh token revoked
// 2. Access token expire naturally (5-15 min)

DB table small (1 row per active session). Revoke = mark column. Check on each refresh.

Recommend pattern 2026:

  • Access token: 5-15 min, JWT stateless.
  • Refresh token: 30d, stored in DB với revocation flag.
  • Logout: revoke refresh + wait access expire.

7. Security pitfalls

7.1 Algorithm "none" attack

Old JWT lib accepted "alg": "none" — no signature. Attacker forge token.

// Header: {"alg":"none","typ":"JWT"}
// Payload: {"sub":"admin","roles":["ADMIN"]}
// Signature: empty

Fix: explicit allowlist algorithms:

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withSecretKey(key)
        .macAlgorithm(MacAlgorithm.HS256)         // explicit allow
        .build();
    // Reject "none" automatically
}

Modern lib (Nimbus, Auth0) reject "none" by default. Verify version.

7.2 Algorithm confusion — RS256 → HS256

If validator accept multiple algorithms, attacker change alg to HS256 + sign with RSA public key (which it has) = signature pass.

Fix: explicit single algorithm in decoder.

7.3 Secret leak

Hardcode HMAC secret = breach.

// SAI
String secret = "my-secret-key";

// DUNG
@Value("${app.jwt.secret}")
String secret;

Externalize qua env var, AWS Secrets Manager, HashiCorp Vault. Rotate periodically.

Min secret length HS256: 256 bits = 32 bytes. Use random generated:

openssl rand -base64 32
# G3wKf9XzN2pQ8vL6tH4mR1yE5sD7uA0bC9wY3jK7iN8=

7.4 No expiry

Token without exp = permanent. Leak = forever access.

Always set exp. Production access token: 5-60 min.

7.5 Token in URL / log

curl https://api.olhub.org/orders?token=eyJ...     # SAI — leak via log

Always Authorization: Bearer ... header. Headers not logged by default.

7.6 Sensitive data in payload

{
  "sub": "[email protected]",
  "ssn": "123-45-6789",          // SAI — payload base64, anyone decode
  "creditCard": "4111111111111111"
}

Payload plaintext base64. Don't put sensitive data. Only ID + roles + minimum claims.

8. Custom user from JWT

@AuthenticationPrincipal returns Jwt object by default. Convert sang custom user:

public record AppUserPrincipal(String email, List<String> roles, String tenantId) {}

@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();

    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        List<String> roles = jwt.getClaimAsStringList("roles");
        return roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .collect(Collectors.toList());
    });

    return converter;
}

// Custom resolver to convert Jwt → AppUserPrincipal
@Component
public class AppUserPrincipalResolver implements HandlerMethodArgumentResolver {

    public boolean supportsParameter(MethodParameter param) {
        return param.hasParameterAnnotation(CurrentUser.class)
            && param.getParameterType().equals(AppUserPrincipal.class);
    }

    public Object resolveArgument(MethodParameter param, ...) {
        Jwt jwt = (Jwt) SecurityContextHolder.getContext()
            .getAuthentication()
            .getPrincipal();

        return new AppUserPrincipal(
            jwt.getSubject(),
            jwt.getClaimAsStringList("roles"),
            jwt.getClaimAsString("tenantId")
        );
    }
}

// Use trong controller
@GetMapping("/me")
public AppUserPrincipal me(@CurrentUser AppUserPrincipal user) {
    return user;
}

9. Pattern cho TaskFlow

// Login
POST /api/auth/login
Body: { email, password }
Response: { accessToken, refreshToken, expiresIn }

// Use API
GET /api/projects
Authorization: Bearer eyJ...

// Refresh
POST /api/auth/refresh
Body: { refreshToken }
Response: { accessToken, refreshToken (new), expiresIn }

// Logout
POST /api/auth/logout
Authorization: Bearer eyJ...
Response: 204 No Content (revoke refresh token in DB)

Module 05 bài 07 (capstone) implement full pipeline.

10. Pitfall tổng hợp

Nhầm 1: Long-lived access token (24h+). ✅ Short access (5-60 min) + refresh token long (30d).

Nhầm 2: Hardcode secret. ✅ Env var, secret manager, vault.

Nhầm 3: No expiry. ✅ Always set exp. Verify decoder check.

Nhầm 4: Sensitive data trong payload. ✅ Only ID + roles. Payload public-readable.

Nhầm 5: No revocation strategy. ✅ Refresh token DB store, denylist Redis cho access token.

Nhầm 6: Algorithm confusion. ✅ Explicit single algorithm trong decoder.

Nhầm 7: Token in URL. ✅ Always Authorization header.

11. 📚 Deep Dive Spring Reference

📚 Tài liệu chính chủ

Standards:

Spring Security:

Tools:

12. Tóm tắt

  • JWT = header.payload.signature (base64url). Payload plaintext — không sensitive data.
  • Standard claims: iss, sub, aud, exp (mandatory), iat, jti (for revocation).
  • HS256 symmetric (1 secret) cho monolith. RS256 asymmetric cho microservices (issuer + validator separate).
  • Spring oauth2ResourceServer.jwt() auto setup BearerTokenAuthenticationFilter + JwtDecoder.
  • JwtAuthenticationConverter custom mapping JWT claims → Authentication authorities.
  • Refresh token pattern: short access (5-60 min) + long refresh (30d) DB-stored.
  • Revocation strategies: short expiry (simple), Redis denylist (selective), DB refresh token table (recommend).
  • Security: explicit algo allowlist, externalize secret, mandatory expiry, no sensitive data, header-only transmission.
  • Custom principal: JwtAuthenticationConverter + HandlerMethodArgumentResolver cho @CurrentUser AppUser.

13. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao production access token nên 5-60 min thay 24h+? Tradeoff với UX?

Reason short-lived:

  1. Token leak window: if attacker steal token, 1h damage vs 24h. Less window.
  2. Revocation latency: JWT stateless — can't easily revoke. Wait for expiry. Short expiry = faster effective revocation when needed.
  3. Compromised user: change password → old token still valid until expiry. Short = quicker invalidation.
  4. Compliance: some standards (PCI-DSS, HIPAA) require token max lifespan.

Tradeoff với UX:

  • Frequent refresh: client refresh 24x/day vs 1x. Network overhead.
  • Logout state on refresh fail: if refresh token also expired/revoked, user logged out unexpectedly.

Refresh token pattern mitigates:

Access token: 15 min — short, frequent refresh
Refresh token: 30d — long, only used when access expires
Login: issue both
On API call: use access token
On 401 (access expired): silently refresh in background, retry call
On refresh fail: redirect to login

Industry standard 2026:

  • Access token: 15 min - 1h.
  • Refresh token: 7-30 days.
  • Sliding refresh: refresh token rotated each use, prevent leak persist.

Implementation client (SPA):

// Axios interceptor — auto-refresh on 401
axios.interceptors.response.use(
  response => response,
  async error => {
      if (error.response?.status === 401 && !error.config._retry) {
          error.config._retry = true;
          const newTokens = await refreshAccessToken();
          saveTokens(newTokens);
          error.config.headers.Authorization = 'Bearer ' + newTokens.accessToken;
          return axios(error.config);
      }
      return Promise.reject(error);
  }
);

UX seamless — user không thấy refresh happen.

Q2
Đoạn JWT decoder sau có vulnerability. Identify + fix.
@Bean
public JwtDecoder jwtDecoder() {
  return JwtDecoders.fromIssuerLocation("https://auth.olhub.org");
}

// Or:
@Bean
public JwtDecoder jwtDecoder() {
  return NimbusJwtDecoder.withSecretKey(key).build();
  // No algorithm specified
}

Vulnerability — algorithm confusion attack.

Without explicit algorithm, decoder may accept multiple algorithms — attacker exploit:

  1. Server expects RS256: validator has RSA public key.
  2. Attacker forge JWT: change alg header to HS256, sign payload using RSA public key as HMAC secret.
  3. Validator try HS256: use RSA public key as HMAC secret. Signature matches because attacker used same key.
  4. Token accepted: attacker forged admin role granted.

Vì sao vulnerability:

  • RSA public key publicly available (JWK endpoint).
  • Attacker know public key.
  • HMAC validation use same key for sign + verify.
  • If validator try HMAC with public key as secret → match attacker's HMAC signature.

Fix — explicit algorithm allowlist:

@Bean
public JwtDecoder jwtDecoder() {
  return NimbusJwtDecoder
      .withJwkSetUri("https://auth.olhub.org/.well-known/jwks.json")
      .jwsAlgorithm(SignatureAlgorithm.RS256)              // explicit
      .build();
}

// For HS256:
@Bean
public JwtDecoder jwtDecoder() {
  return NimbusJwtDecoder
      .withSecretKey(key)
      .macAlgorithm(MacAlgorithm.HS256)                     // explicit
      .build();
}

Decoder reject any other algorithm — including "none" attack.

Other JWT vulnerabilities to guard:

  1. "none" algorithm: early lib accepted no signature. Modern lib reject by default. Verify version Nimbus 9+ / java-jwt 4+.
  2. Weak HMAC secret: 256-bit minimum. Use openssl rand -base64 32. Reject short keys.
  3. Missing expiration: validate exp claim mandatory.
  4. Audience mismatch: validate aud matches expected service.
  5. Issuer mismatch: validate iss matches expected auth server.

Defense in depth:

@Bean
public JwtDecoder jwtDecoder() {
  NimbusJwtDecoder decoder = NimbusJwtDecoder
      .withJwkSetUri("https://auth.olhub.org/.well-known/jwks.json")
      .jwsAlgorithm(SignatureAlgorithm.RS256)
      .build();

  OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
      new JwtTimestampValidator(),                              // exp + nbf
      new JwtIssuerValidator("https://auth.olhub.org"),
      new JwtAudienceValidator("olhub-api")
  );

  decoder.setJwtValidator(validator);
  return decoder;
}

public static class JwtAudienceValidator implements OAuth2TokenValidator<Jwt> {
  private final String expectedAudience;
  public OAuth2TokenValidatorResult validate(Jwt jwt) {
      if (jwt.getAudience().contains(expectedAudience)) {
          return OAuth2TokenValidatorResult.success();
      }
      return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_audience"));
  }
}

Multi-layer validation — algo + timestamp + issuer + audience.

Q3
Refresh token revocation strategies — DB store vs Redis denylist. So sánh.
AspectDB store (refresh tokens table)Redis denylist (revoked JTIs)
Default state"Allow if exists in DB""Allow unless in denylist"
Store size1 row / active session1 entry / revoked token (until expiry)
StoragePostgres (with TaskFlow infra)Redis (separate)
Lookup speed~5ms (PostgreSQL with index)~1ms (Redis in-memory)
RevocationMark revoked = trueAdd JTI to denylist
Logout single sessionEasy — revoke specific refresh tokenEasy — add JTI
Logout all sessionsEasy — revoke all user's refresh tokensHard — would need user→JTIs index
Mass revocation (compromise)Easy — UPDATE WHERE user_id = XHard — need denylist all active tokens
Best forRefresh tokens (long-lived, stateful OK)Access tokens (short-lived, occasional revocation)

Hybrid pattern (recommend production):

@Entity
public class RefreshToken {
  @Id String id;                  // JTI
  String username;
  Instant issuedAt;
  Instant expiresAt;
  boolean revoked;

  @CreatedDate Instant createdAt;
}

@Service
public class RefreshTokenService {

  public void issue(String username, String jti, Instant expiresAt) {
      // Optional: revoke previous refresh tokens (single session per user)
      refreshRepo.revokeAllByUsername(username);

      RefreshToken token = new RefreshToken();
      token.setId(jti);
      token.setUsername(username);
      token.setExpiresAt(expiresAt);
      refreshRepo.save(token);
  }

  public boolean isValid(String jti) {
      return refreshRepo.findById(jti)
          .filter(t -> !t.isRevoked() && t.getExpiresAt().isAfter(Instant.now()))
          .isPresent();
  }

  public void revoke(String jti) {
      refreshRepo.findById(jti).ifPresent(t -> {
          t.setRevoked(true);
          refreshRepo.save(t);
      });
  }

  public void revokeAllForUser(String username) {
      refreshRepo.revokeAllByUsername(username);     // mass revoke (e.g. password change)
  }
}

Logout endpoint:

@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody RefreshRequest req) {
  Jwt refreshJwt = refreshTokenDecoder.decode(req.refreshToken());
  refreshTokenService.revoke(refreshJwt.getId());
  return ResponseEntity.noContent().build();
}

Cleanup expired tokens (scheduled):

@Scheduled(cron = "0 0 2 * * *")     // 2am daily
public void cleanupExpiredTokens() {
  Instant cutoff = Instant.now();
  int deleted = refreshRepo.deleteByExpiresAtBefore(cutoff);
  log.info("Deleted {} expired refresh tokens", deleted);
}

Keep DB lean. Auto-cleanup row passed expiry.

Recommend:

  • Refresh tokens: DB store (TaskFlow has Postgres anyway).
  • Access tokens: short-lived 15min, no revocation needed (wait expire).
  • Mass invalidation (password change, account compromise): revoke all refresh tokens for user.
Q4
HS256 vs RS256 — concrete decision criteria.
CriterionHS256RS256
Issuer = validatorYes (monolith)No (microservices, IdP)
Key sharing1 secret, both sign + verifyPrivate (sign) + public (verify) separated
Performance~10x faster sign/verifySlower (RSA math)
Key rotationCoordinate secret rotation between issuer + validator simultaneouslyIssuer rotate keys, publish new public via JWKS — validators auto-fetch
Setup complexitySimple (1 secret)Complex (key pair gen, JWK endpoint)
Best forSingle app issuer + validatorMultiple microservices, third-party IdP

Decision tree:

Is auth issuer same app as API server?
Yes (monolith) → HS256 (simpler)
No (separated) → Continue

Multiple validator services?
Yes → RS256 (validators only need public key, no secret share)
No → HS256 still OK

Third-party IdP (Auth0, Cognito, Keycloak)?
Yes → RS256 (IdP industry standard, JWKS endpoint)
No → HS256

Compliance / security audit?
Strict → RS256 (asymmetric better practice)
Standard → HS256 OK with proper secret management

HS256 setup (TaskFlow Module 05):

# application.yml
app:
jwt:
  secret: ${JWT_SECRET}     # 256-bit random key from env

# Generate key:
# openssl rand -base64 32

@Bean
public JwtEncoder jwtEncoder() {
  SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
  JWKSource<SecurityContext> source = (sel, ctx) ->
      sel.select(new JWKSet(new OctetSequenceKey.Builder(key).build()));
  return new NimbusJwtEncoder(source);
}

@Bean
public JwtDecoder jwtDecoder() {
  SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
  return NimbusJwtDecoder.withSecretKey(key)
      .macAlgorithm(MacAlgorithm.HS256)
      .build();
}

RS256 setup (microservices Module 11):

# Auth service
@Bean
public JwtEncoder jwtEncoder() {
  KeyPair keyPair = loadKeyPair();
  JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
      .privateKey(keyPair.getPrivate())
      .keyID("kid-2026-04")
      .build();
  JWKSource<SecurityContext> source = new ImmutableJWKSet<>(new JWKSet(jwk));
  return new NimbusJwtEncoder(source);
}

# API services (validator only)
@Bean
public JwtDecoder jwtDecoder() {
  return NimbusJwtDecoder
      .withJwkSetUri("https://auth.olhub.org/.well-known/jwks.json")
      .jwsAlgorithm(SignatureAlgorithm.RS256)
      .build();
}

# Auth service expose JWKS endpoint
@RestController
public class JwksController {
  @GetMapping("/.well-known/jwks.json")
  public Map<String, Object> jwks() {
      return jwkSet.toJSONObject();    // public keys only
  }
}

API services validate without sharing secret. Auth service rotate keys → validators auto-fetch new public keys.

Recommend cho TaskFlow Module 05: HS256 — single app issuer + validator. Migrate RS256 ở Module 11 (microservices).

Q5
Custom claim extract: app có "tenantId" claim trong JWT cho multi-tenant. Inject `tenantId` vào controller method.

3 approaches:

1. Manual extract from Jwt principal:

@GetMapping("/projects")
public List<Project> projects(@AuthenticationPrincipal Jwt jwt) {
  String tenantId = jwt.getClaimAsString("tenantId");
  return service.findByTenant(tenantId);
}

Simple but verbose, repeat in every controller method.

2. Custom @CurrentUser annotation + resolver:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}

public record AppUserPrincipal(String email, List<String> roles, String tenantId) {

  public static AppUserPrincipal from(Jwt jwt) {
      return new AppUserPrincipal(
          jwt.getSubject(),
          jwt.getClaimAsStringList("roles"),
          jwt.getClaimAsString("tenantId")
      );
  }
}

@Component
public class CurrentUserResolver implements HandlerMethodArgumentResolver {

  public boolean supportsParameter(MethodParameter param) {
      return param.hasParameterAnnotation(CurrentUser.class)
          && param.getParameterType().equals(AppUserPrincipal.class);
  }

  public Object resolveArgument(MethodParameter param, ...) {
      Authentication auth = SecurityContextHolder.getContext().getAuthentication();
      if (auth == null || !(auth.getPrincipal() instanceof Jwt jwt)) {
          return null;
      }
      return AppUserPrincipal.from(jwt);
  }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
  @Autowired CurrentUserResolver resolver;

  public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
      resolvers.add(resolver);
  }
}

// Usage
@GetMapping("/projects")
public List<Project> projects(@CurrentUser AppUserPrincipal user) {
  return service.findByTenant(user.tenantId());
}

Clean, type-safe, reusable.

3. ThreadLocal TenantContext (multi-tenant aware):

@Component
public class TenantFilter extends OncePerRequestFilter {

  protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
      Authentication auth = SecurityContextHolder.getContext().getAuthentication();
      if (auth != null && auth.getPrincipal() instanceof Jwt jwt) {
          String tenantId = jwt.getClaimAsString("tenantId");
          TenantContext.setCurrentTenant(tenantId);
      }
      try {
          chain.doFilter(req, res);
      } finally {
          TenantContext.clear();
      }
  }
}

public class TenantContext {
  private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
  public static void setCurrentTenant(String tenantId) { currentTenant.set(tenantId); }
  public static String getCurrentTenant() { return currentTenant.get(); }
  public static void clear() { currentTenant.remove(); }
}

// Usage anywhere — service, repository
@Service
public class ProjectService {
  public List<Project> findAll() {
      String tenantId = TenantContext.getCurrentTenant();
      return repo.findByTenantId(tenantId);
  }
}

Multi-tenant pattern — tenantId implicit, no need pass param everywhere. Hibernate filter, JPA tenant interceptor, etc.

Recommend:

  • Single field (just tenantId): approach 3 — TenantContext.
  • Multiple user fields (email, roles, tenantId): approach 2 — @CurrentUser annotation.
  • Quick prototype: approach 1 — direct extract.

Pattern enterprise multi-tenant: approach 2 + 3 combined. @CurrentUser for explicit access, TenantContext for implicit (Hibernate filters).

Q6
JWT expire window. User logout, server can't immediately invalidate access token. Acceptable? Mitigate?

JWT stateless — by design, can't immediately invalidate.

Acceptable trade-off, with mitigation strategies.

Logout flow JWT-based:

User clicks "Logout"
↓
Frontend: clear tokens from localStorage / cookie
Frontend: POST /api/auth/logout
↓
Server: revoke refresh token in DB
↓
Frontend: navigate to /login
↓
Window: access token still valid until expiry (e.g. 15 min remaining)
- If frontend cleared tokens: API calls won't have token → 401 → re-login required
- If attacker stole token before logout: still valid for ~15 min

Mitigation strategies:

  1. Short access token expiry (5-15 min):
    expiresAt = Instant.now().plus(Duration.ofMinutes(15))
    Window of stolen token = max 15 min. Acceptable for most apps.
  2. Revoke refresh token:
    @PostMapping("/logout")
    public void logout(@RequestBody RefreshRequest req) {
      Jwt refreshJwt = refreshDecoder.decode(req.refreshToken());
      refreshTokenService.revoke(refreshJwt.getId());
      // Access token expires naturally in ~15 min, refresh blocked
    }
    User cannot get new access token after refresh expires.
  3. Optional Redis denylist for access tokens (real-time invalidation):
    @PostMapping("/logout")
    public void logout(@RequestHeader("Authorization") String authHeader,
                      @RequestBody RefreshRequest req) {
      String accessToken = authHeader.substring(7);   // strip "Bearer "
      Jwt accessJwt = jwtDecoder.decode(accessToken);
    
      Duration remaining = Duration.between(Instant.now(), accessJwt.getExpiresAt());
      redisDenylist.add(accessJwt.getId(), remaining);
    
      refreshTokenService.revoke(req.refreshTokenId());
    }
    
    // Filter check denylist for every request
    @Component
    public class AccessTokenDenylistFilter extends OncePerRequestFilter {
      protected void doFilterInternal(...) {
          String token = extract(req);
          if (token != null) {
              String jti = jwtDecoder.decode(token).getId();
              if (denylist.contains(jti)) {
                  response.setStatus(401);
                  return;
              }
          }
          chain.doFilter(...);
      }
    }
    Real-time revoke. Tradeoff: stateful — defeat JWT purpose somewhat.
  4. Token versioning (mass revoke):
    # JWT claim
    {
      "sub": "[email protected]",
      "ver": 5,            // user.tokenVersion
      "exp": ...
    }
    
    # Validator
    String username = jwt.getSubject();
    int tokenVer = jwt.getClaim("ver");
    int currentVer = userRepo.findByEmail(username).getTokenVersion();
    if (tokenVer < currentVer) throw new InvalidTokenException("Token version invalidated");
    
    # Force logout all users
    UPDATE users SET token_version = token_version + 1;
    # Or per user
    UPDATE users SET token_version = token_version + 1 WHERE id = ?;
    Mass invalidation when need (compromise, mass logout).

Recommend pattern:

  • Default: short access (15 min) + DB refresh revoke. Acceptable trade-off.
  • High-security (banking): add Redis denylist for access tokens. Real-time revoke.
  • Enterprise (audit): token versioning. Audit log all auth events.

UX considerations:

  • Frontend immediately clear tokens on logout — cannot use even though server still valid.
  • If attacker has token before logout, they keep using until expiry. Acceptable risk most apps.
  • For "logout from all devices": revoke all refresh tokens for user + bump token version.

Bài tiếp theo: Method security — @PreAuthorize, @PostAuthorize, @Secured

Bài này có giúp bạn hiểu bản chất không?

Bình luận (0)

Đang tải...