JWT: xác thực stateless cho REST API

5 câu hỏi~35 phútTừ khóa: JWT, header.payload.signature, HS256, RS256, claims, Bearer, oauth2ResourceServer, JwtDecoder, JwtEncoder, JwtAuthenticationConverter, OncePerRequestFilter, stateless, revocation, localStorage

Tiếp đi: JWT.

Ở bài 4.4.2, login chỉ kiểm tra credentials. Bài trước dùng session để nhớ đăng nhập. Giờ ta trả lời: sau khi login, làm sao request tiếp theo chứng minh đã đăng nhập mà không gửi lại password và server không cần nhớ session? Đó là JWT (JSON Web Token).

1. Bài toán

POST /api/auth/login   {"username":"john","password":"<password>"}
→ 200 Credentials verified

GET /api/orders
→ 401 Unauthorized

shop-app là REST API, có mobile app và SPA gọi vào, chạy nhiều instance sau load balancer. Ta muốn client gửi:

GET /api/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

2. Vấn đề

3. Giải pháp: JWT tự chứa thông tin, có chữ ký

HEADER.PAYLOAD.SIGNATURE

Header:

{ "alg": "HS256", "typ": "JWT" }

Payload (claims):

{
  "sub": "john",
  "roles": ["ROLE_USER"],
  "iss": "shop-app",
  "iat": 1791400000,
  "exp": 1791401800
}
ClaimÝ nghĩa
subSubject: user id hoặc username (nên dùng id ổn định)
issIssuer: ai phát hành
audAudience: API nào được nhận
iat / expThời điểm tạo / hết hạn
jtiID của token, dùng cho revoke
rolesClaim tự định nghĩa

Quan trọng: payload chỉ được Base64URL encode, không mã hóa. Ai có token cũng decode đọc được. Không để password hay dữ liệu nhạy cảm trong payload.

Signature (HS256):

HMACSHA256(base64url(header) + "." + base64url(payload), secret)

Attacker sửa "roles":["ROLE_USER"] thành ROLE_ADMIN nhưng không có secret thì không tạo được chữ ký hợp lệ. JWT phát hiện token bị sửa, nhưng không ngăn người khác dùng token nếu bị đánh cắp.

4. Code từng bước

Bước 1: Dependency. Dùng Resource Server có sẵn của Spring Security thay vì tự viết parser:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Bước 2: Config secret (không commit secret vào Git):

app.jwt.secret=${JWT_SECRET_BASE64}
# tạo 32 bytes ngẫu nhiên, đặt vào biến môi trường JWT_SECRET_BASE64
openssl rand -base64 32

Bước 3: Code Java. Encoder và Decoder:

@Configuration
public class JwtConfig {

    @Bean
    public SecretKey jwtSecretKey(@Value("${app.jwt.secret}") String secret) {
        byte[] keyBytes = Base64.getDecoder().decode(secret);
        if (keyBytes.length < 32) {
            throw new IllegalArgumentException("JWT secret must be at least 256 bits");
        }
        return new SecretKeySpec(keyBytes, "HmacSHA256");
    }

    @Bean
    public JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    public JwtDecoder jwtDecoder(SecretKey key) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
            .macAlgorithm(MacAlgorithm.HS256)
            .build();
        decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer("shop-app")); // exp + iss
        return decoder;
    }
}

Phát token khi login:

@Service
public class JwtService {
    private final JwtEncoder jwtEncoder;
    public JwtService(JwtEncoder jwtEncoder) { this.jwtEncoder = jwtEncoder; }

    public String generateToken(Authentication auth) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("shop-app")
            .subject(auth.getName())
            .id(UUID.randomUUID().toString())          // jti
            .issuedAt(now)
            .expiresAt(now.plus(15, ChronoUnit.MINUTES))
            .claim("roles", auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList())
            .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }
}

// AuthService
public String login(LoginRequest request) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.username(), request.password()));
    return jwtService.generateToken(authentication);
}

// AuthController
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest request) {
    return new TokenResponse(authService.login(request), "Bearer");
}
public record TokenResponse(String accessToken, String tokenType) {}

SecurityFilterChain: thay httpBasic bằng oauth2ResourceServer, và map claim roles thành authorities (mặc định Spring chỉ đọc scope/scp và thêm prefix SCOPE_):

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())   // chỉ nhận Bearer qua header, không cookie
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
        roles.setAuthoritiesClaimName("roles");
        roles.setAuthorityPrefix("");          // token đã chứa ROLE_USER / ROLE_ADMIN
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(roles);
        return converter;
    }
}

Bước 4: Test bằng curl

TOKEN=$(curl -s -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" \
        -d '{"username":"john","password":"<password>"}' | jq -r .accessToken)

curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/orders
# Orders for john

curl -i -H "Authorization: Bearer $TOKEN" localhost:8080/api/admin/users
# 403

curl -i -H "Authorization: Bearer abc.def.ghi" localhost:8080/api/orders
# 401, header WWW-Authenticate: Bearer error="invalid_token"

5. Cách hoạt động

HTTP Request (Authorization: Bearer ...)
     |
BearerTokenAuthenticationFilter     trích token
     |
AuthenticationManager
     |
JwtAuthenticationProvider
     |
JwtDecoder                          verify signature, exp, iss (aud nếu cấu hình)
     |
JwtAuthenticationConverter          claims → authorities
     |
JwtAuthenticationToken → SecurityContextHolder (chỉ cho request này)
     |
AuthorizationFilter → DispatcherServlet → Controller
Session-based                      JWT-based
Client → Session ID                Client → Signed JWT
Server → Session Store → User      Server → Verify Token → User

Stateless không có nghĩa là không bao giờ cần Redis/DB: refresh token, revoke, quyền thay đổi vẫn cần state phía server.

6. So sánh

oauth2ResourceServer().jwt()Tự viết OncePerRequestFilter
CodeVài dòng cấu hìnhTự parse header, verify, set context
Validate exp/iss/aud, lỗi chuẩnCó sẵn, trả WWW-Authenticate đúng chuẩnTự làm, dễ sót
Đổi sang Keycloak/RS256Chỉ đổi issuer-uriViết lại
Khi nàoMặc định nên dùngĐịnh dạng token đặc biệt, hệ thống cũ
HS256 (symmetric)RS256 / ES256 (asymmetric)
KhóaMột secret để ký và verifyPrivate key ký, public key verify
Ai verify đượcAi có secret (cũng ký được!)Bất kỳ ai có public key (JWKS)
Hợp vớiMột app tự phát tự kiểmNhiều microservice, Keycloak
Lưu token ở đâu (web)Rủi ro
localStorageXSS đọc được token
Memory (biến JS)Mất khi reload, cần refresh token
Cookie HttpOnly; Secure; SameSiteJS không đọc được, nhưng browser tự gửi → phải tính CSRF
BFF (backend giữ token, browser chỉ có session)An toàn nhất cho SPA, thêm một tầng

7. Khi nào nên dùng / không nên dùng

User bấm logout thì JWT có tự hết hiệu lực không?

Không. Đây là trade-off lớn nhất của JWT. Server chỉ kiểm chữ ký và exp, không tra DB, nên token còn hạn vẫn được chấp nhận. Các cách xử lý:

Chiến lượcƯuNhược
Access token ngắn hạnĐơn giản, dễ scaleCòn sống tới khi hết hạn
Revoke refresh tokenChặn cấp token mớiAccess token hiện tại vẫn chạy
Denylist jti trong Redis (TTL = thời gian còn lại)Revoke ngayThêm 1 lookup Redis/request
Token version theo userThu hồi mọi token của userPhải tra version hiện tại
Opaque token + introspectionServer kiểm soát hoàn toànMất tính stateless

Denylist muốn có hiệu lực phải nằm trong bước validate, ví dụ một OAuth2TokenValidator<Jwt> tùy chỉnh gắn vào JwtDecoder:

OAuth2TokenValidator<Jwt> notRevoked = jwt ->
    Boolean.TRUE.equals(redis.hasKey("revoked:jwt:" + jwt.getId()))
        ? OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Token revoked", null))
        : OAuth2TokenValidatorResult.success();

decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
    JwtValidators.createDefaultWithIssuer("shop-app"), notRevoked));

JWT có role = ADMIN, sau đó admin bị hạ xuống USER trong database. Token cũ còn quyền ADMIN không?

Còn, cho tới khi hết hạn, nếu server chỉ tin claim trong token. JWT là snapshot quyền tại thời điểm phát hành. Đây chính là lý do access token phải ngắn hạn. Với thao tác nhạy cảm (xóa user, chuyển tiền), có thể kiểm lại quyền từ DB, hoặc dùng token version để vô hiệu hóa token cũ khi quyền thay đổi.

Vậy JWT có mã hóa không? Sao tôi dán lên jwt.io đọc được hết.

Đúng như bạn thấy: JWT thông thường là JWS (signed), chỉ ký, không mã hóa. Chữ ký bảo đảm toàn vẹn và nguồn gốc, không bảo đảm bí mật. Muốn mã hóa payload thì dùng JWE, nhưng hiếm khi cần; cách đúng là đừng đặt dữ liệu nhạy cảm vào token và luôn đi qua HTTPS. Cũng đừng dán token production lên trang web bên ngoài.

Phỏng vấn hỏi về JWT thì trả lời sao?

Q1. What is JWT and why is it stateless?

🇻🇳 Tiếng Việt:

JWT là định dạng token gọn, URL-safe, gồm header, payload (claims) và signature. Server verify chữ ký và các claim như exp, iss mà không cần tra session, nên gọi là stateless và dễ scale ngang. Payload chỉ encode, không mã hóa.

🇬🇧 English:

JWT is a compact, URL-safe token made of a header, a payload of claims and a signature. The server verifies the signature and claims like exp and iss without a session lookup, so it is stateless and scales horizontally. The payload is only encoded, not encrypted.

Q2. Spring Security validate JWT thế nào? Có nên tự viết filter?

🇻🇳 Tiếng Việt:

Với oauth2ResourceServer().jwt(), BearerTokenAuthenticationFilter lấy token, JwtAuthenticationProvider dùng JwtDecoder verify chữ ký, exp, iss, rồi JwtAuthenticationConverter map claim thành authorities và đặt vào SecurityContext. Em ưu tiên cách này hơn tự viết OncePerRequestFilter, vì đã chuẩn hóa validate và lỗi, và đổi sang Keycloak chỉ cần đổi issuer-uri.

🇬🇧 English:

With oauth2ResourceServer().jwt(), BearerTokenAuthenticationFilter extracts the token, JwtAuthenticationProvider uses a JwtDecoder to verify the signature, exp and iss, and JwtAuthenticationConverter maps claims to authorities in the security context. I prefer it over a custom OncePerRequestFilter because validation and error handling are standard, and switching to Keycloak only means changing the issuer-uri.

Q3. Can JWT be revoked immediately?

🇻🇳 Tiếng Việt:

Không tự động. Token hợp lệ tới khi hết hạn. Em dùng access token ngắn hạn kết hợp refresh token có thể revoke; nếu cần revoke ngay thì denylist jti trong Redis với TTL bằng thời gian còn lại, hoặc token version, chấp nhận thêm một lookup.

🇬🇧 English:

Not automatically; a signed JWT stays valid until it expires. I use short-lived access tokens with revocable refresh tokens. If immediate revocation is required, I add a Redis denylist of jti values with a TTL equal to the remaining lifetime, or a token version, accepting one extra lookup per request.

Q4. Where should a web client store the token?

🇻🇳 Tiếng Việt:

Không có chỗ hoàn hảo. localStorage dễ bị XSS đọc. Cookie HttpOnly; Secure; SameSite chống JS đọc nhưng phải tính CSRF. Với SPA, em giữ access token trong memory và refresh token trong HttpOnly cookie, hoặc dùng BFF. Quyết định theo loại client và threat model.

🇬🇧 English:

There is no perfect place. localStorage is readable by XSS. An HttpOnly; Secure; SameSite cookie hides it from JavaScript but brings CSRF back into scope. For SPAs, I keep the access token in memory and the refresh token in an HttpOnly cookie, or use a BFF. It depends on the client type and the threat model.

Cách nhớ ngắn gọn


Tự kiểm tra

Ai có token có đọc được payload không?

Có, payload chỉ Base64URL encode. Vì vậy không đặt dữ liệu nhạy cảm vào đó.

Dùng oauth2ResourceServer mà hasRole("ADMIN") luôn 403 dù token có claim roles. Vì sao?

Mặc định Spring chỉ map scope/scp thành SCOPE_*. Cần JwtAuthenticationConverter đọc claim roles.

Logout xong, access token cũ còn dùng được không nếu không có denylist?

Còn, tới khi hết hạn.

Vì sao microservices thường dùng RS256 thay HS256?

Service chỉ cần public key để verify; không service nào giữ khóa ký nên không tự phát token giả được.

Gọi API với token sai chữ ký nhận mã gì?

401, kèm WWW-Authenticate: Bearer error="invalid_token".