Spring Security & Testing/JWT structure & validation — base64url, signing, Spring oauth2ResourceServer
10/20
Bài 10 / 20~13 phútAuthenticationMiễn phí lượt xem

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ầnNội dungVí dụ decoded
HeaderAlgorithm + token type{"alg":"HS256","typ":"JWT"}
PayloadClaims — thông tin user + metadata{"sub":"[email protected]","roles":["USER"],"exp":1713792400}
SignatureHMAC hoặc RSA signature của header.payloadbytes 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.

flowchart LR
    subgraph JWT["JWT compact: header.payload.signature"]
        H["Header<br/>alg + typ<br/>base64url"]
        dot1["."]
        P["Payload<br/>claims: sub, exp, roles<br/>base64url"]
        dot2["."]
        S["Signature<br/>HMAC/RSA<br/>base64url"]
    end
    H --- dot1 --- P --- dot2 --- S

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 token
  • sub (subject) — chủ thể, thường là user ID hoặc email
  • aud (audience) — service nào được phép dùng token này
  • exp (expiration) — Unix timestamp hết hạn — bắt buộc production; thiếu exp = token bị lộ là permanent breach
  • iat (issued at) — thời điểm phát hành
  • jti (JWT ID) — unique ID, dùng cho revocation
  • roles — 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.

flowchart LR
    Secret["secret (256-bit)"]
    Data["base64url(header).base64url(payload)"]
    Sign["HMAC-SHA256"]
    Sig["signature"]
    Data --> Sign
    Secret --> Sign
    Sign --> Sig
// 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 signpublic 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).

flowchart TB
    subgraph AuthService["Auth Service (issuer)"]
        PK["Private Key<br/>chi auth service cam"]
        SignOp["RSA-SHA256 sign"]
    end
    subgraph APIService["API Service (validator)"]
        PubKey["Public Key<br/>public, ai cung doc duoc"]
        VerifyOp["RSA-SHA256 verify"]
    end
    Token["JWT token"]
    PK --> SignOp --> Token
    Token --> VerifyOp
    PubKey --> VerifyOp
    VerifyOp --> Result["valid / invalid"]
// 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:

  1. 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ỗ.
  2. 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.
  3. 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ệ.
HS256RS256
Key typeSymmetric — 1 secretAsymmetric — private + public
Ai signIssuer (secret)Issuer (private key)
Ai verifyBất kỳ ai có secretBất kỳ ai có public key
Secret shareBắt buộc giữa issuer + validatorKhông — public key là public
Blast radius khi key lộToàn hệ thống cần xoay đồng thờiChỉ auth service (private key)
Tốc độ~10x nhanh hơnChậm hơn (RSA math)
Key rotationCoordinate manualAuto qua JWKS endpoint
Chọn khiMonolith (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

flowchart TB
    Req["Request: Authorization: Bearer eyJ..."]
    BFilter["BearerTokenAuthenticationFilter<br/>extract token tu header"]
    JProvider["JwtAuthenticationProvider<br/>goi JwtDecoder.decode(token)"]
    Decoder["JwtDecoder (NimbusJwtDecoder)<br/>1. verify signature<br/>2. check exp + nbf<br/>3. parse claims"]
    Converter["JwtAuthenticationConverter<br/>JWT claims -> GrantedAuthority"]
    SC["SecurityContextHolder<br/>Authentication da xac thuc"]
    Controller["Controller"]

    Req --> BFilter
    BFilter --> JProvider
    JProvider --> Decoder
    Decoder -->|"valid"| Converter
    Converter --> SC
    SC --> Controller
    Decoder -->|"invalid sig / expired"| Err["401 Unauthorized"]

Bốn bước xảy ra trước khi request tới controller:

  1. BearerTokenAuthenticationFilter đọc header Authorization: Bearer <token>, extract chuỗi JWT.
  2. JwtAuthenticationProvider gọi JwtDecoder.decode(token).
  3. NimbusJwtDecoder verify signature (dùng key đã config), kiểm tra exp chưa qua và nbf đã qua — nếu bất kỳ check nào fail, decoder ném exception và request nhận 401.
  4. JwtAuthenticationConverter nhận Jwt đã verified, map claims thành Collection<GrantedAuthority> rồi tạo JwtAuthenticationToken lưu vào SecurityContextHolder.

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(...)@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_USERROLE_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 + Spring docs

RFC stack:

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 qua SecurityFilterChain — hiểu filter chain của bài đó giúp hình dung vị trí BearerTokenAuthenticationFilter trong 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). Converter JwtAuthenticationConverter viết ở bài này được dùng trực tiếp.
  • Security architecture & FilterChain: BearerTokenAuthenticationFilter là một trong các filter trong SecurityFilterChain — hiểu chuỗi filter giúp debug khi JWT bị reject ở đúng stage nào.
  • Method security — @PreAuthorize: authority ROLE_ADMIN extract bằng JwtAuthenticationConverter ở bài này là input cho @PreAuthorize("hasRole('ADMIN')") ở bài method security.

Tóm tắt

  • JWT (RFC 7519) = header.payload.signature base64url — payload plaintext, không để sensitive data.
  • Standard claims bắt buộc production: sub, exp (mandatory), iat, jti (revocation). Thiếu exp = 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ài BearerTokenAuthenticationFilter + JwtDecoder validate signature + exp — không cần tự viết filter.
  • JwtAuthenticationConverter map claim roles → authority ROLE_* cho hasRole(...)@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

Tự kiểm tra
Q1
JWT được gọi là "stateless" vì lý do gì? Điều đó có nghĩa là gì với server khi validate request?

"Stateless" ở đây nghĩa là server không cần lưu bất kỳ thông tin nào về token — mọi thứ cần để validate đều nằm trong chính token: thông tin user trong payload, chữ ký trong signature, thời hạn qua exp.

Khi nhận request, server chỉ cần: (1) verify signature bằng key đã config — đảm bảo token chưa bị giả mạo; (2) check exp chưa qua — đảm bảo token còn hiệu lực. Không cần tra database session, không cần dùng cache, không cần gọi auth service. Đây là lý do JWT scale tốt cho microservices — mỗi service tự validate độc lập.

Đánh đổi: vì không lưu state, server không thể "xoá" token ngay lập tức khi user logout — token vẫn valid đến hết exp. Đây là bài toán revocation, giải bằng short expiry + refresh token pattern (bài tiếp theo).

Q2
Tạ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.

Với HS256: secret phải được share giữa auth service và mọi API service. Nếu một API service bị compromise và attacker lấy được secret, họ có thể tự phát hành token giả với bất kỳ claim nào (kể cả roles:["ADMIN"]). Để khắc phục phải xoay secret đồng thời ở auth service lẫn toàn bộ API services — phức tạp và rủi ro downtime.

Với RS256: API service chỉ cầm public key để verify. Public key là "public" — lộ ra ngoài cũng không gây hại vì không thể dùng public key để sign token. Chỉ private key ở auth service mới sign được, và private key không rời auth service. API service bị compromise không cho phép attacker issue token giả. Blast radius được giới hạn ở auth service duy nhất.

Thêm vào đó, RS256 hỗ trợ key rotation dễ hơn: auth service publish public key mới qua JWKS endpoint, API services tự fetch khi cần — không cần rolling restart đồng thời toàn cluster.

Q3
oauth2ResourceServer.jwt() trong Spring Security tự động làm những gì? Nếu token có signature hợp lệ nhưng exp đã qua, response trả về là gì?

oauth2ResourceServer.jwt() tự cài một pipeline gồm ba thành phần: BearerTokenAuthenticationFilter extract chuỗi token từ header Authorization: Bearer ...; JwtAuthenticationProvider gọi JwtDecoder.decode(token); NimbusJwtDecoder verify signature bằng key đã config và kiểm tra exp, nbf.

Nếu token có signature hợp lệ nhưng exp đã qua, NimbusJwtDecoder ném JwtValidationException với message "Jwt expired at...". Spring Security bắt exception này và trả về 401 Unauthorized — request không bao giờ tới controller.

Không cần tự kiểm tra exp trong code business. Đây chính xác là lý do dùng oauth2ResourceServer.jwt() thay vì tự parse header thủ công.

Q4
Algorithm confusion attack là gì? Viết đúng một dòng config Spring Security ngăn attack này với RS256 decoder.

Algorithm confusion attack xảy ra khi decoder chấp nhận nhiều algorithm. Attacker tạo JWT với header {"alg":"HS256"} thay vì RS256, rồi ký payload bằng RSA public key (vốn công khai, ai cũng biết) như HMAC secret. Validator thử HS256 với public key — signature khớp — token giả được chấp nhận, attacker tự gán role bất kỳ.

Nguyên nhân gốc: RSA public key là bytes bình thường, có thể dùng làm HMAC secret. Nếu validator không pin algorithm, nó thử nhiều algorithm cho đến khi tìm thấy cái khớp.

Fix — pin algorithm tường minh khi build decoder:

NimbusJwtDecoder.withJwkSetUri(uri)
  .jwsAlgorithm(SignatureAlgorithm.RS256)
  .build();

Decoder reject ngay bất kỳ JWT nào dùng algorithm khác RS256, kể cả alg:none.

Q5
Vì 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?

String.getBytes() không có argument dùng platform default charset của JVM — giá trị này khác nhau tùy OS và cấu hình. Trên macOS và hầu hết Linux hiện đại, default là UTF-8. Nhưng trong một số container Alpine, JVM cũ, hoặc môi trường Windows, default có thể là ISO-8859-1 hoặc US-ASCII.

Hậu quả: auth service (dev) tạo key từ secret bytes UTF-8, validator (container) tạo key từ secret bytes ISO-8859-1 — hai key bytes khác nhau dù cùng string. Signature mismatch → 100% request trả 401 Unauthorized. Bug không có exception rõ ràng, chỉ thấy "JWT signature does not match" — cực kỳ khó trace khi không biết nguyên nhân là charset.

StandardCharsets.UTF_8 là constant trong JDK, không ném exception, đảm bảo cùng bytes trên mọi môi trường. Luôn chỉ định tường minh khi convert string sang bytes cho crypto.

Bài tiếp theo: Issue, refresh & revoke JWT

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

Đặt 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