JWT structure & validation — base64url, signing, Spring oauth2ResourceServer
JWT (RFC 7519) là token tự đóng gói gồm header.payload.signature mã hoá base64url. Bài này bóc đúng một thứ: cấu trúc token, cơ chế signing HMAC (HS256) vs RSA (RS256), và Spring Security oauth2ResourceServer.jwt() validate signature + expiry tự động — cùng lý do chọn RS256 cho microservices.
TL;DR: JWT (RFC 7519) = chuỗi header.payload.signature mã hoá base64url — payload chứa claims (sub, exp, roles), signature đảm bảo toàn vẹn. HS256 (HMAC-SHA256) dùng 1 secret chia sẻ giữa issuer và validator — đơn giản nhưng blast radius toàn hệ thống nếu key lộ. RS256 (RSA-SHA256) asymmetric: private key sign ở auth service, public key verify ở mọi service — microservices chọn RS256 vì validator không cần cầm secret. Spring Security oauth2ResourceServer.jwt() tự cài BearerTokenAuthenticationFilter + JwtDecoder validate signature + exp mà không cần viết thêm filter. JwtAuthenticationConverter map claim roles thành GrantedAuthority.
Bài Form login & HTTP Basic dùng session-based auth — server lưu trạng thái. Bài này chuyển sang JWT: stateless, không session, token tự mang đủ thông tin. Câu hỏi atomic: token đó trông như thế nào bên trong, được ký ra sao, và Spring validate nó ở đâu trong filter chain?
1. JWT structure — ba phần phân tách bằng dấu chấm
JWT (JSON Web Token, RFC 7519) là chuẩn định nghĩa cách đóng gói claim thành một chuỗi compact, có chữ ký, truyền được qua HTTP header. Một JWT trông như sau:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyQG9saHViLnZuIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MTM3OTI0MDB9
.QmJC_3xNpHt8BKr5vZLd2mX9sA7cT1uY4wE6fN0oP8k
Ba phần, mỗi phần là base64url (RFC 4648 §5 — biến thể URL-safe: + → -, / → _, không pad =):
| Phần | Nội dung | Ví dụ decoded |
|---|---|---|
| Header | Algorithm + token type | {"alg":"HS256","typ":"JWT"} |
| Payload | Claims — thông tin user + metadata | {"sub":"[email protected]","roles":["USER"],"exp":1713792400} |
| Signature | HMAC hoặc RSA signature của header.payload | bytes nhị phân, base64url-encoded |
Điểm cốt lõi: payload là plaintext base64 — ai decode cũng đọc được. JWT không phải encryption, chỉ là signed token. Không bao giờ đặt password, số thẻ tín dụng, hay PII nhạy cảm trong payload.

Chữ ký chứng minh hai phần bên trái chưa bị sửa. Nó không giấu gì cả: payload là base64url, dán vào jwt.io là đọc ra ngay. Nên nhét password hay số thẻ vào payload là công bố chúng, không phải bảo vệ chúng.
1.1 Standard claims (RFC 7519)
{
"iss": "olhub.vn",
"sub": "[email protected]",
"aud": "olhub-api",
"exp": 1713792400,
"iat": 1713788800,
"jti": "abc-123-uuid",
"roles": ["USER"]
}
iss(issuer) — ai phát hành tokensub(subject) — chủ thể, thường là user ID hoặc emailaud(audience) — service nào được phép dùng token nàyexp(expiration) — Unix timestamp hết hạn — bắt buộc production; thiếuexp= token bị lộ là permanent breachiat(issued at) — thời điểm phát hànhjti(JWT ID) — unique ID, dùng cho revocationroles— custom claim (không phải standard, nhưng phổ biến)
2. Cơ chế signing — tại sao cần signature?
Payload base64 là plaintext — ai cũng đọc được. Vậy signature ngăn cái gì? Ngăn giả mạo. Nếu attacker sửa "roles":["USER"] thành "roles":["ADMIN"], signature sẽ không khớp nữa — validator phát hiện ngay.
Cơ chế: signature = sign(base64url(header) + "." + base64url(payload), key). Validator chạy lại cùng phép tính — nếu kết quả khớp thì token chưa bị sửa và được phát hành từ đúng issuer.
2.1 HS256 — HMAC-SHA256 (symmetric)
HMAC (Hash-based Message Authentication Code, RFC 2104) là cơ chế ký dùng một secret duy nhất cho cả hai việc: sign và verify. Algorithm HS256 trong JWT = HMAC-SHA256.
// Config bean — HS256 decoder
@Value("${app.jwt.secret}")
private String secret;
@Bean
public JwtDecoder jwtDecoder() {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
StandardCharsets.UTF_8 phải chỉ định tường minh — String.getBytes() không có argument dùng platform default, khác nhau giữa JVM, gây lỗi khó debug khi deploy sang môi trường khác charset.
Ưu điểm HS256: đơn giản, nhanh (~10 lần nhanh hơn RSA), setup 1 secret là xong.
Nhược điểm — blast radius: secret phải được share giữa issuer và mọi validator. Nếu một service trong hệ thống lộ secret, attacker có thể tự issue token giả với bất kỳ claim nào — phải xoay key đồng thời toàn bộ issuer + validator. Đây chính xác là vấn đề HS256 tạo ra khi scale lên nhiều service.
Khi nào chọn HS256: issuer và validator là cùng một app (monolith) — secret không cần rời khỏi một process.
2.2 RS256 — RSA-SHA256 (asymmetric)
RSA là crypto asymmetric: cặp khoá private + public được sinh cùng nhau, có tính chất toán học đặc biệt. Private key sign → public key verify — và chiều ngược lại không khả thi tính toán (không thể suy ra private từ public).
Khác biệt thực dụng giữa hai algorithm lộ ra rõ nhất khi chạy cùng một kịch bản qua cả hai thiết kế — attacker chiếm được một API service thì làm được gì:

Khác biệt nằm ở chỗ ai cầm được khoá ký. HS256 buộc mọi validator cầm nó, nên bề mặt tấn công lớn bằng số service. RS256 giữ khoá ký một chỗ.
// Auth service — encoder dung private key sign
@Bean
public JwtEncoder jwtEncoder() {
KeyPair keyPair = loadKeyPair(); // RSA 2048-bit minimum
JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey(keyPair.getPrivate())
.keyID("kid-2026")
.build();
JWKSource<SecurityContext> source = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(source);
}
// API service — decoder chi can public key, lay tu JWKS endpoint
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("https://auth.olhub.vn/.well-known/jwks.json")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
}
Tại sao microservices chọn RS256:
Auth service cầm private key — không bao giờ chia sẻ. Mỗi API service chỉ cần public key để verify, fetch từ JWKS endpoint (/.well-known/jwks.json). Hệ quả trực tiếp:
- Blast radius bị giới hạn: lộ public key vô hại (nó vốn là "public"). Chỉ lộ private key ở auth service mới nguy hiểm — và private key chỉ nằm ở một chỗ.
- Key rotation không cần coordinate: auth service đổi key pair, publish public key mới lên JWKS, các API service tự fetch về. Không cần rolling restart đồng thời.
- Zero-trust validator: API service không cần trust auth service tại deploy time — chỉ cần trust rằng public key đến từ JWKS endpoint hợp lệ.
| HS256 | RS256 | |
|---|---|---|
| Key type | Symmetric — 1 secret | Asymmetric — private + public |
| Ai sign | Issuer (secret) | Issuer (private key) |
| Ai verify | Bất kỳ ai có secret | Bất kỳ ai có public key |
| Secret share | Bắt buộc giữa issuer + validator | Không — public key là public |
| Blast radius khi key lộ | Toàn hệ thống cần xoay đồng thời | Chỉ auth service (private key) |
| Tốc độ | ~10x nhanh hơn | Chậm hơn (RSA math) |
| Key rotation | Coordinate manual | Auto qua JWKS endpoint |
| Chọn khi | Monolith (issuer = validator) | Microservices, nhiều validator |
3. Spring oauth2ResourceServer.jwt() — validation tự động
Spring Security không buộc bạn tự viết filter parse JWT. Config oauth2ResourceServer.jwt() tự cài một pipeline đầy đủ:
@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();
}
}
3.1 Cơ chế bên dưới — pipeline validate JWT

Bốn bước xảy ra trước khi request tới controller:
BearerTokenAuthenticationFilterđọc headerAuthorization: Bearer <token>, extract chuỗi JWT.JwtAuthenticationProvidergọiJwtDecoder.decode(token).NimbusJwtDecoderverify signature (dùng key đã config), kiểm traexpchưa qua vànbfđã qua — nếu bất kỳ check nào fail, decoder ném exception và request nhận 401.JwtAuthenticationConverternhậnJwtđã verified, map claims thànhCollection<GrantedAuthority>rồi tạoJwtAuthenticationTokenlưu vàoSecurityContextHolder.
Tại sao validate signature + exp? Signature đảm bảo token chưa bị giả mạo; exp đảm bảo token còn trong thời hạn phát hành. Thiếu signature check thì bất kỳ ai cũng tự tạo được token. Thiếu exp check thì token bị lộ trở thành vĩnh viễn.
3.2 JwtAuthenticationConverter — extract authority từ claim
Mặc định Spring đọc authority từ claim scope với prefix SCOPE_ (chuẩn OAuth2 RFC 6750). Khi app tự issue token với claim roles và prefix ROLE_ (cho hasRole(...) và @PreAuthorize), phải override converter:
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthoritiesClaimName("roles"); // doc tu claim "roles"
authoritiesConverter.setAuthorityPrefix("ROLE_"); // prefix cho hasRole(...)
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("sub"); // username = sub claim
return converter;
}
Khi JWT chứa "roles": ["USER", "ADMIN"], converter tạo authority ROLE_USER và ROLE_ADMIN — khớp với hasRole("ADMIN") hay @PreAuthorize("hasRole('ADMIN')") ở method security.
3.3 Custom user object từ JWT
Mặc định @AuthenticationPrincipal trả về object Jwt. Nếu cần struct rõ ràng hơn:
public record AppUserPrincipal(String email, List<String> roles) {
public static AppUserPrincipal from(Jwt jwt) {
return new AppUserPrincipal(
jwt.getSubject(),
jwt.getClaimAsStringList("roles")
);
}
}
// Dung trong controller
@GetMapping("/me")
public AppUserPrincipal me(@AuthenticationPrincipal Jwt jwt) {
return AppUserPrincipal.from(jwt);
}
Cách mở rộng thêm annotation @CurrentUser với HandlerMethodArgumentResolver được trình bày chi tiết ở bài 04 — Issue, refresh & revoke JWT.
Pitfall của riêng concept này
Pitfall 1 — String.getBytes() không chỉ định charset:
// SAI — platform default charset, khac giua JVM
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
// DUNG — luon chi dinh tuong minh
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Trên môi trường dev (macOS, UTF-8 mặc định) code chạy đúng. Deploy lên container Alpine (charset có thể khác) thì signature mismatch — 401 cho tất cả request. Bug khó debug vì không có exception rõ ràng, chỉ thấy token "invalid".
Pitfall 2 — Algorithm confusion attack (RS256 → HS256):
// SAI — chap nhan nhieu algorithm
NimbusJwtDecoder.withJwkSetUri(uri).build();
// DUNG — explicit single algorithm
NimbusJwtDecoder.withJwkSetUri(uri)
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
Nếu decoder chấp nhận cả HS256 lẫn RS256, attacker đổi header "alg":"HS256" rồi ký payload bằng RSA public key (vốn là public) — validator chạy HS256 verify với chính public key đó, kết quả khớp, và token giả được chấp nhận. Luôn pin một algorithm.
Pitfall 3 — Thiếu exp trong payload:
// SAI — khong dat expiry
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(user.getUsername())
.build();
// DUNG
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(user.getUsername())
.expiresAt(Instant.now().plus(Duration.ofHours(1)))
.build();
Token không có exp = permanent. Token bị lộ là breach mãi mãi — không có gì để revoke. JwtDecoder của Spring không tự reject token không có exp trừ khi cấu hình JwtTimestampValidator.
Pitfall 4 — Sensitive data trong payload:
// SAI — payload la plaintext base64
{"sub":"[email protected]", "password":"secret123", "creditCard":"4111111111111111"}
// DUNG — chi ID + roles + minimum
{"sub":"[email protected]", "roles":["USER"], "exp":1713792400}
Payload base64 — ai cầm token cũng decode đọc được ngay tại https://jwt.io. Chỉ đặt claims cần thiết cho authorization.
📚 Deep Dive
RFC stack:
- RFC 7519 — JSON Web Token — chuẩn gốc: structure, standard claims, validation rules
- RFC 7515 — JSON Web Signature — JWS compact serialization (format 3 phần), algorithm requirements
- RFC 4648 §5 — Base 64 Encoding with URL and Filename Safe Alphabet — base64url spec
- RFC 2104 — HMAC — cơ chế HMAC
Spring Security:
- OAuth2 Resource Server — JWT — toàn bộ config option: JWK set URI, issuer URI, public key, custom decoder
- JwtEncoder — issue JWT từ Spring Security
Tools:
- jwt.io — decode JWT online, xem 3 phần rõ ràng (đừng paste production token)
openssl rand -base64 32— sinh HS256 secret đủ 256 bit
Liên hệ các bài khác
- Form login & HTTP Basic: bài trước dùng session-based auth —
UsernamePasswordAuthenticationFilter+HttpSession. Bài này thay session bằng JWT stateless. Cả hai đều chạy quaSecurityFilterChain— hiểu filter chain của bài đó giúp hình dung vị tríBearerTokenAuthenticationFiltertrong pipeline. - Issue, refresh & revoke JWT: bài sau implement login endpoint phát hành token (
JwtEncoder), refresh token pattern (access 15 phút + refresh 30 ngày), và revocation strategy (DB store + Redis denylist). ConverterJwtAuthenticationConverterviết ở bài này được dùng trực tiếp. - Security architecture & FilterChain:
BearerTokenAuthenticationFilterlà một trong các filter trongSecurityFilterChain— hiểu chuỗi filter giúp debug khi JWT bị reject ở đúng stage nào. - Method security — @PreAuthorize: authority
ROLE_ADMINextract bằngJwtAuthenticationConverterở bài này là input cho@PreAuthorize("hasRole('ADMIN')")ở bài method security.
Tóm tắt
- JWT (RFC 7519) =
header.payload.signaturebase64url — payload plaintext, không để sensitive data. - Standard claims bắt buộc production:
sub,exp(mandatory),iat,jti(revocation). Thiếuexp= token permanent. - HS256 symmetric (1 secret) → đơn giản nhưng secret phải share — chọn khi issuer = validator cùng app.
- RS256 asymmetric (private sign + public verify) → validator không cần cầm secret, blast radius giới hạn ở auth service — chọn cho microservices.
oauth2ResourceServer.jwt()tự càiBearerTokenAuthenticationFilter+JwtDecodervalidate signature +exp— không cần tự viết filter.JwtAuthenticationConvertermap claimroles→ authorityROLE_*chohasRole(...)và@PreAuthorize.getBytes(StandardCharsets.UTF_8)— luôn chỉ định charset, không dùng platform default.- Pin algorithm trong decoder — ngăn algorithm confusion attack (RS256 → HS256).
Tự kiểm tra
- Q1JWT được gọi là "stateless" vì lý do gì? Điều đó có nghĩa là gì với server khi validate request?
- Q2Tại sao microservices nên chọn RS256 thay HS256? Mô tả cụ thể điều gì xảy ra khi một API service bị compromise trong mỗi trường hợp.
- Q3
oauth2ResourceServer.jwt()trong Spring Security tự động làm những gì? Nếu token có signature hợp lệ nhưngexpđã qua, response trả về là gì? - Q4Algorithm confusion attack là gì? Viết đúng một dòng config Spring Security ngăn attack này với RS256 decoder.
- Q5Vì sao phải viết
secret.getBytes(StandardCharsets.UTF_8)thay vìsecret.getBytes()khi tạo HMAC key? Hậu quả nếu không làm vậy?
Bài tiếp theo: Issue, refresh & revoke JWT
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên