JWT: dùng đúng và các lỗi bảo mật hay gặp
1. Tổng quan
JWT (JSON Web Token) được dùng khắp nơi để xác thực API, nhưng rất nhiều hệ thống triển khai sai dẫn đến lỗ hổng nghiêm trọng. Bài này tổng hợp cách tạo, verify JWT đúng cách trong Spring Boot, kèm các lỗi bảo mật kinh điển (alg confusion, secret yếu, không check expiration…) và cách phòng tránh. Đọc xong, bạn sẽ biết cách cấu hình JWT an toàn và review code JWT của đồng nghiệp tự tin hơn.
2. Cấu trúc JWT và cơ chế hoạt động
JWT gồm 3 phần: header.payload.signature, encode base64url, ngăn bởi dấu chấm.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
- Header: chứa
alg(thuật toán ký) vàtyp. - Payload: chứa claims như
sub,exp,iat,roles. - Signature: chữ ký để xác minh token không bị sửa.
Lưu ý: JWT không mã hóa payload, chỉ encode. Ai cũng đọc được nội dung nếu có token. Đừng nhét password, PII nhạy cảm vào claims.
3. Thêm dependency
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Thư viện jjwt (io.jsonwebtoken) phổ biến nhất cho Java, API rõ ràng và mặc định đã chặn một số lỗi hay gặp.
4. Tạo và verify token đúng cách
4.1. Sinh secret key đủ mạnh
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class JwtKeyFactory {
public static SecretKey generateKey() {
return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
}
}
Keys.secretKeyFor sinh key ngẫu nhiên đủ độ dài cho HS256 (≥ 256 bit). Trong production, lưu key này ở secret manager (Vault, AWS Secrets Manager), không hardcode trong code.
4.2. Tạo token với TokenService
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
public class TokenService {
private final SecretKey key;
public TokenService(SecretKey key) {
this.key = key;
}
public String generateToken(String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(900))) // 15 phút
.signWith(key)
.compact();
}
}
expirationđặt 15 phút — access token nên sống ngắn.signWith(key)tự chọn alg phù hợp với loại key, tránh để client can thiệp vàoalg.
4.3. Verify token
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
public class TokenVerifier {
private final SecretKey key;
public TokenVerifier(SecretKey key) {
this.key = key;
}
public Claims verify(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
throw new InvalidTokenException("Token không hợp lệ hoặc đã hết hạn", e);
}
}
}
parseSignedClaims tự động kiểm tra signature và exp. Nếu sai signature hoặc token hết hạn, nó throw JwtException — bạn bắt lại thành exception domain của mình.
5. Lỗi bảo mật #1: alg=none hoặc alg confusion
Kẻ tấn công sửa header thành {"alg":"none"} rồi gửi token không có signature. Nếu backend không ép buộc algorithm, nó có thể chấp nhận token giả.
// SAI: không chỉ định algorithm được phép
Jwts.parser().build().parseSignedClaims(token); // thư viện cũ dễ bị lừa
Cách phòng tránh — luôn whitelist algorithm:
import io.jsonwebtoken.Jwts;
Jwts.parser()
.verifyWith(key) // chỉ accept key đã biết
.build()
.parseSignedClaims(token); // jjwt 0.12+ tự chặn alg=none
Lưu ý: Với các thư viện JWT khác (jose4j, nimbus-jose-jwt), luôn cấu hình whitelist algorithm rõ ràng (ví dụ chỉ cho phép
HS256). Không bao giờ tinalgfield trong token do client gửi lên.
Một biến thể nguy hiểm hơn: alg confusion giữa RS256 và HS256. Nếu server verify bằng public key RSA nhưng code lại dùng nó làm secret cho HMAC khi alg=HS256, attacker có thể tự ký token bằng public key (vốn công khai). Giải pháp: cố định algorithm theo use case, không để code tự đọc alg từ token rồi chọn cách verify tương ứng.
6. Lỗi bảo mật #2: secret yếu hoặc lộ secret
// SAI: secret ngắn, dễ brute-force
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
HS256 yêu cầu key ≥ 256 bit (32 byte). Secret ngắn có thể bị brute-force offline bằng tool như hashcat trong vài giờ.
// ĐÚNG: secret đủ dài, random
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
Ngoài ra:
- Không commit secret vào Git. Dùng biến môi trường hoặc secret manager.
- Rotate secret định kỳ, đặc biệt sau khi nhân sự có quyền truy cập secret rời đi.
# application.properties - đọc từ env, không hardcode
jwt.secret=${JWT_SECRET}
7. Lỗi bảo mật #3: không kiểm tra expiration hoặc token sống quá lâu
// SAI: access token sống 30 ngày
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))
Access token sống quá lâu nghĩa là nếu bị lộ, attacker có quyền truy cập dài hạn mà bạn không thể revoke (JWT là stateless, không có cơ chế logout mặc định).
Pattern đúng: access token ngắn (5–15 phút) + refresh token dài hơn, lưu ở server (hoặc rotate) để có thể revoke.
public class TokenPair {
private final String accessToken; // sống 15 phút
private final String refreshToken; // sống 7 ngày, lưu DB để revoke được
// constructor, getters
}
public class RefreshTokenRepository {
public boolean isRevoked(String refreshTokenId) {
// query DB/Redis, nếu token đã bị logout thì true
return false;
}
}
Khi refresh token bị revoke, access token cũ vẫn còn sống đến khi hết hạn — đây là lý do access token phải ngắn.
8. Lỗi bảo mật #4: nhét dữ liệu nhạy cảm vào payload
// SAI: payload là public, ai có token cũng đọc được
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())
Payload chỉ base64-encode, không mã hóa. Dùng [jwt.io](dạng text, không phải link) decode là thấy hết. Chỉ nên để claims cần cho authorization: sub, roles, exp, scope.
// ĐÚNG
.subject(user.getUsername())
.claim("roles", user.getRoles())
9. Lỗi bảo mật #5: không validate issuer/audience khi dùng chung secret cho nhiều service
Nếu nhiều service dùng cùng secret để ký token, token phát hành cho Service A có thể bị replay sang Service B nếu không kiểm tra aud (audience).
import io.jsonwebtoken.Jwts;
Claims claims = Jwts.parser()
.verifyWith(key)
.requireAudience("order-service") // chỉ chấp nhận token dành cho service này
.requireIssuer("auth-server")
.build()
.parseSignedClaims(token)
.getPayload();
requireAudience và requireIssuer ép buộc parser throw exception nếu claim không khớp — chặn được việc token bị dùng sai mục đích.
10. Tích hợp với Spring Security filter
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthFilter extends OncePerRequestFilter {
private final TokenVerifier verifier;
public JwtAuthFilter(TokenVerifier verifier) {
this.verifier = verifier;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
var claims = verifier.verify(header.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
}
}
chain.doFilter(request, response);
}
}
- Lấy token từ header
Authorization: Bearer <token>. - Verify bằng
TokenVerifierđã viết ở mục 4.3. - Nếu token sai, clear context — request sẽ bị chặn bởi Spring Security ở bước authorization tiếp theo, không throw exception trực tiếp trong filter.
Lưu ý: Không để JWT trong
localStoragenếu frontend là SPA public — dễ bị XSS đánh cắp. Ưu tiên lưu trong cookieHttpOnly,Secure,SameSite=Strictkhi có thể.
11. Test hành vi verify token
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
class TokenVerifierTest {
private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
private final TokenService tokenService = new TokenService(key);
private final TokenVerifier verifier = new TokenVerifier(key);
@Test
void shouldVerifyValidToken() {
String token = tokenService.generateToken("alice");
var claims = verifier.verify(token);
assertThat(claims.getSubject()).isEqualTo("alice");
}
@Test
void shouldRejectTokenSignedWithDifferentKey() {
SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
TokenService fakeService = new TokenService(otherKey);
String forgedToken = fakeService.generateToken("attacker");
assertThatThrownBy(() -> verifier.verify(forgedToken))
.isInstanceOf(InvalidTokenException.class);
}
}
Test thứ hai mô phỏng chính xác tình huống attacker tự ký token bằng key khác — verifier phải reject, không được âm thầm chấp nhận.
shouldVerifyValidToken() PASS
shouldRejectTokenSignedWithDifferentKey() PASS
12. Checklist nhanh trước khi deploy
- Secret ≥ 256 bit, lưu ở secret manager, không hardcode.
- Whitelist algorithm, không tin
algtừ token. - Access token sống ngắn (≤ 15 phút), có refresh token để revoke.
- Không nhét dữ liệu nhạy cảm vào payload.
- Validate
exp,iss,audkhi cần. - Lưu token ở
HttpOnlycookie thay vìlocalStoragekhi frontend là SPA. - Có cơ chế revoke/blacklist cho refresh token.
13. Kết luận
JWT bản thân không có lỗi — hầu hết sự cố đến từ cách triển khai: secret yếu, tin alg từ client, token sống quá lâu, hoặc nhét dữ liệu nhạy cảm vào payload công khai. Áp dụng checklist ở mục 12 và luôn dùng thư viện JWT đã qua kiểm thử (jjwt, nimbus-jose-jwt) thay vì tự viết parser, bạn sẽ tránh được phần lớn lỗ hổng phổ biến. Bước tiếp theo đáng tìm hiểu: OAuth 2.0 / OpenID Connect để quản lý vòng đời token tốt hơn ở quy mô nhiều service.