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):
| Part | Content | Example |
|---|---|---|
| Header | Algorithm + token type | {"alg":"HS256","typ":"JWT"} |
| Payload | Claims (subject, roles, expiry) | {"sub":"[email protected]","roles":["USER"],"iat":1713788800,"exp":1713792400} |
| Signature | HMAC/RSA signature of header.payload | binary 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 --> JWTDecode 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
| Algorithm | Key type | Use case |
|---|---|---|
| HS256 | Symmetric (1 secret) | Issuer = validator (same app) |
| RS256 | Asymmetric (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:
BearerTokenAuthenticationFilterparseAuthorization: Bearer ...header.JwtAuthenticationProvidervalidate token (signature + expiry).JwtAuthenticationConverterbuildAuthenticationfrom 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
Standards:
- RFC 7519 — JSON Web Token
- RFC 7515 — JWS — signature.
- RFC 7517 — JWK — public key set.
- OWASP JWT Cheat Sheet
Spring Security:
Tools:
- jwt.io — decode JWT online (don't paste production tokens).
- Auth0 Java JWT — popular library.
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. JwtAuthenticationConvertercustom mapping JWT claims →Authenticationauthorities.- 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+HandlerMethodArgumentResolvercho@CurrentUser AppUser.
13. Tự kiểm tra
Q1Vì sao production access token nên 5-60 min thay 24h+? Tradeoff với UX?▸
Reason short-lived:
- Token leak window: if attacker steal token, 1h damage vs 24h. Less window.
- Revocation latency: JWT stateless — can't easily revoke. Wait for expiry. Short expiry = faster effective revocation when needed.
- Compromised user: change password → old token still valid until expiry. Short = quicker invalidation.
- 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 loginIndustry 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
}
▸
@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:
- Server expects RS256: validator has RSA public key.
- Attacker forge JWT: change
algheader toHS256, sign payload using RSA public key as HMAC secret. - Validator try HS256: use RSA public key as HMAC secret. Signature matches because attacker used same key.
- 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:
- "none" algorithm: early lib accepted no signature. Modern lib reject by default. Verify version Nimbus 9+ / java-jwt 4+.
- Weak HMAC secret: 256-bit minimum. Use
openssl rand -base64 32. Reject short keys. - Missing expiration: validate
expclaim mandatory. - Audience mismatch: validate
audmatches expected service. - Issuer mismatch: validate
issmatches 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.
Q3Refresh token revocation strategies — DB store vs Redis denylist. So sánh.▸
| Aspect | DB store (refresh tokens table) | Redis denylist (revoked JTIs) |
|---|---|---|
| Default state | "Allow if exists in DB" | "Allow unless in denylist" |
| Store size | 1 row / active session | 1 entry / revoked token (until expiry) |
| Storage | Postgres (with TaskFlow infra) | Redis (separate) |
| Lookup speed | ~5ms (PostgreSQL with index) | ~1ms (Redis in-memory) |
| Revocation | Mark revoked = true | Add JTI to denylist |
| Logout single session | Easy — revoke specific refresh token | Easy — add JTI |
| Logout all sessions | Easy — revoke all user's refresh tokens | Hard — would need user→JTIs index |
| Mass revocation (compromise) | Easy — UPDATE WHERE user_id = X | Hard — need denylist all active tokens |
| Best for | Refresh 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.
Q4HS256 vs RS256 — concrete decision criteria.▸
| Criterion | HS256 | RS256 |
|---|---|---|
| Issuer = validator | Yes (monolith) | No (microservices, IdP) |
| Key sharing | 1 secret, both sign + verify | Private (sign) + public (verify) separated |
| Performance | ~10x faster sign/verify | Slower (RSA math) |
| Key rotation | Coordinate secret rotation between issuer + validator simultaneously | Issuer rotate keys, publish new public via JWKS — validators auto-fetch |
| Setup complexity | Simple (1 secret) | Complex (key pair gen, JWK endpoint) |
| Best for | Single app issuer + validator | Multiple 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 managementHS256 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).
Q5Custom 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 —
@CurrentUserannotation. - Quick prototype: approach 1 — direct extract.
Pattern enterprise multi-tenant: approach 2 + 3 combined. @CurrentUser for explicit access, TenantContext for implicit (Hibernate filters).
Q6JWT 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 minMitigation strategies:
- Short access token expiry (5-15 min):Window of stolen token = max 15 min. Acceptable for most apps.
expiresAt = Instant.now().plus(Duration.ofMinutes(15)) - Revoke refresh token:User cannot get new access token after refresh expires.
@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 } - Optional Redis denylist for access tokens (real-time invalidation):Real-time revoke. Tradeoff: stateful — defeat JWT purpose somewhat.
@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(...); } } - Token versioning (mass revoke):Mass invalidation when need (compromise, mass logout).
# 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 = ?;
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...