Một access token thực chất chỉ là ba chuỗi nối nhau bằng dấu chấm. Tại sao lại phải phức tạp hóa nó lên bằng hàng tá khái niệm hàn lâm? Hãy thử hiểu chúng theo một cách đơn giản nhất: Mực, Nội dung và Chữ ký.
Cách xác thực cũ giống như việc cấp thẻ thành viên tạm thời. Server giữ một cuốn sổ lưu trữ, phát cho client một mã số ngẫu nhiên (Session ID). Mỗi lần client gõ cửa, server lại phải lật cuốn sổ ra tra xem mã này của ai. An toàn, dễ kiểm soát, nhưng lại tốn một lượt đọc cơ sở dữ liệu cho mọi request. Khi hệ thống to lên, cuốn sổ đó trở thành nút thắt cổ chai.
JWT sinh ra để giải quyết bài toán đó bằng một góc nhìn khác: Đưa luôn toàn bộ thông tin cho client tự cầm. Thay vì tra sổ, Server cấp cho client một văn bản chứa sẵn thông tin, và dùng "chiếc bút" bí mật của mình để đóng Chữ ký lên đó. Lần sau client mang văn bản tới, Server không cần nhớ gì cả, nó chỉ cần nhìn vào chữ ký và tự hỏi: "Đây có đúng là chữ ký do chiếc bút của mình tạo ra không?"
Từ đó sinh ra ba phần trong một JWT. Trong bài này, chúng ta sẽ gọi chúng tương ứng là A (Mực), B (Nội dung), và C (Chữ ký).
Đây là token thật, được ký bằng chiếc bút mang tên sieu-bi-mat. Mọi dữ liệu trong bài đều lấy từ nó — bạn có thể copy và dán vào jwt.io để tự kiểm chứng.
C = HMAC(secret, A + "." + B) → token = A.B.CC' = HMAC(secret, A + "." + B) → C' == C ?Nhìn vào hai dòng trên, ta có thể hình dung: Secret là chiếc bút, A là loại mực, B là nội dung, và kết hợp chúng lại ta được C là chữ ký.
Khi kiểm tra, nếu người dùng đưa một token với Mực (A) hoặc Nội dung (B) đã bị sửa đổi, Server sẽ lấy chiếc bút (Secret) của mình ký thử lại lên A và B đó để tạo ra C'. Rõ ràng, Chữ ký C' lúc này sẽ khác hoàn toàn với chữ ký C ban đầu. Hacker có thể tự do sửa đổi mực và nội dung văn bản, nhưng vì không có "chiếc bút" của Server, chúng không thể làm ra một chữ ký C mới cho khớp được.
Chỉ vậy thôi, không có phép mật mã thứ hai nào trong toàn bộ hệ thống. Ba điểm mà ai mới học cũng vấp, tôi xin nói trước cho gọn:
Nếu bạn decode phần A ra, bạn sẽ được đúng một object bé xíu:
{"alg":"HS256","typ":"JWT"}
alg là algorithm — khai báo loại hàm băm tạo ra C. Bạn có thể hiểu nó giống như việc khai báo loại mực mà chiếc bút sẽ dùng:
| alg | Hàm tạo ra C | Khoá (Chiếc bút) |
|---|---|---|
HS256 | HMAC-SHA256 | một secret chung |
HS384 / HS512 | HMAC với SHA-384/512 | như trên, chữ ký dài hơn |
RS256 | chữ ký số RSA + SHA-256 | private ký, public kiểm |
ES256 | ECDSA đường cong P-256 | như RSA, khoá ngắn hơn |
none | không có — C rỗng | như kiểu dùng mực tàng hình |
Vì header luôn là object đó, mọi token HS256 trên đời đều mở đầu bằng cùng 36 ký tự eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. Và mọi JWT đều bắt đầu bằng eyJ, đơn giản vì đó là base64 của {".
Hãy ghim alg lại trong đầu. Trông nó có vẻ là thành phần vô hại nhất, nhưng thực chất lại là cội nguồn của lỗ hổng kinh điển nhất trong JWT — chúng ta sẽ bàn ở phần cuối.
{"sub":7,"email":"nam@example.com","iat":1787820315,"exp":1787821215}
Điều quan trọng nhất về B, cũng là điều bị hiểu nhầm nhiều nhất: Base64 không phải là mã hoá. Nó chỉ là một cách viết lại chữ cái. Bất kỳ ai chặn được token đều đọc được trọn vẹn B mà không cần chiếc bút (secret). Nhớ kỹ: Secret không dùng để giấu nội dung B — nó dùng để chống sửa nội dung B.
Các trường trong B gọi là claim. Chuẩn JWT định nghĩa sẵn bảy cái tên viết tắt ba ký tự:
| Claim | Nghĩa | Thư viện tự xử lý? |
|---|---|---|
exp | expiration — mốc hết hạn | Có, tự từ chối khi quá hạn |
iat | issued at — lúc ký | Có, dùng để tính exp |
nbf | not before — chưa hiệu lực trước mốc này | Có |
sub | subject — token nói về ai | Không, bạn tự dùng |
iss | issuer — ai phát hành | Chỉ khi bạn khai issuer lúc verify |
aud | audience — dành cho service nào | Chỉ khi bạn khai audience |
jti | JWT ID — định danh riêng từng token | Không |
Có ba thứ tuyệt đối không bao giờ được đặt vào B: mật khẩu (kể cả hash), dữ liệu nhạy cảm như thẻ tín dụng, và bất cứ thứ gì khiến cho việc thay đổi một field có thể cấp cho user một quyền thực thi lớn (ví dụ: role). Lý do thứ ba tinh tế hơn hai cái đầu — B bị "đóng băng" ngay lúc ký. Nếu bạn nhét role: ADMIN vào đó, thì dù user bị hạ quyền ở phút thứ 3, họ vẫn cầm cái token ghi chữ ADMIN đi lộng hành suốt 12 phút còn lại. Quyền hạn thay đổi liên tục thì phải tra lại từ DB, không nên nhét chết vào token.
Nếu A và B ai cũng có thể đọc và viết, thì C chính là "dấu triện" đóng lên A và B đó. Nó là 32 byte kết quả của thuật toán HMAC-SHA256, viết lại dưới dạng base64url thành 43 ký tự.
Con số 43 không phải ngẫu nhiên: 32 byte cho ra 44 ký tự base64, bỏ một dấu = đệm ở cuối đi thì còn 43. Nếu thấy chữ ký C có độ dài khác 43, token đó đã hỏng từ trong trứng nước, không cần phải tính toán xác minh làm gì cho tốn sức.
iat lệch 1 giây), toàn bộ chữ ký C thay đổi sạch sẽ, không còn ký tự nào giống bản cũ. Không có chuyện sửa nhẹ thì chữ ký sai nhẹ.Nhiều người lầm tưởng rằng nếu hash(secret, X) = Y thì hẳn toán học phải có cách để hash(secret, Y) = X. Không hề! Tính chất đảo ngược đó là của Mã hoá, không phải của Băm (Hash).
Lý do sâu xa nhất là vì thông tin đã bị tiêu hủy vĩnh viễn trong quá trình băm. SHA-256 luôn nhả ra đúng 32 byte, bất kể đầu vào của bạn to nhỏ thế nào:
| Đầu vào | Kích thước | SHA-256 cho ra |
|---|---|---|
| Một ký tự | 1 byte | 4048c449… 32 byte |
| Một câu ngắn | 15 byte | 4d165189… 32 byte |
| Một cuốn sách | 1 000 000 byte | 31a87ed2… 32 byte |
Nhét một triệu byte vào, lấy ra đúng 32 byte. Phần dữ liệu thừa không hề bị "nén" đi đâu cả — nó đơn giản là biến mất. Đầu vào là vô hạn, nhưng đầu ra chỉ giới hạn ở 2²⁵⁶ khả năng, chắc chắn phải có vô số chuỗi khác nhau cùng cho ra một mã băm. Đã mất dữ liệu thì không thể dịch ngược.
Bí mật về chiếc bút: Tính chất trên khẳng định một điều: Dù SECRET_KEY lưu trong file .env của bạn chỉ ngắn đúng 1 ký tự hay dài tới 10.000 ký tự, khi băm ra, nó luôn cho một chữ ký C có độ dài chuẩn xác 32 byte (43 ký tự b64). Kẻ tấn công nhìn vào chữ ký sẽ mù tịt, không thể biết chiếc bút của bạn dài bao nhiêu để mà dò.
Bên trái là Ký (Tạo chữ ký), bên phải là Kiểm (Xác thực) — chạy cục bộ ngay trên trình duyệt của bạn. Hãy thử sửa đổi Nội dung bên phải, hoặc đổi tên Secret key (đổi bút) để tự mình thấy chuyện gì sẽ xảy ra.
Trong code thực tế, bạn chỉ viết một dòng: jwtService.sign({ sub, email }). Nhưng đằng sau đó, thư viện đã làm 7 việc:
sub, email. Chưa có thời gian hay thuật toán gì ở đây.iat và expiat = giây hiện tại, exp = iat + expiresIn.alg là HS256.A + "." + BTạo ra một chuỗi dài 129 byte làm phôi gốc.Thuật toán băm là hàm thuần túy, không có yếu tố ngẫu nhiên (salt). Nên nếu một người đăng nhập 2 lần trong cùng một giây, server sẽ sinh ra 2 token giống hệt nhau tới từng ký tự. Vô hại — nhưng điều đó chứng tỏ iat là cứu cánh duy nhất để phân biệt các token sinh ra ở thời điểm khác nhau. Nếu bạn dùng blacklist theo mã băm của token, việc logout một thiết bị có thể "giết nhầm" luôn phiên của thiết bị kia vừa tạo ra ở cùng giây đó. Để chắc ăn nhất, hãy chèn thêm một trường mã ngẫu nhiên jti vào Nội dung B.
jwt malformed ngay.algPhần B vẫn nằm im lìm nguyên khối, chưa ai đụng vào.alg này có được phép dùng không?Chốt chặn an ninh số 1. Chưa thèm tính toán gì cả.C' = HMAC(secret, A + "." + B)Dùng đúng chuỗi byte nhận được từ client.C' với C một cách thận trọng (timing-safe)Chốt chặn an ninh số 2. Nếu dùng ===, phép toán sẽ dừng ngay ở ký tự sai đầu tiên, khiến kẻ tấn công có thể đo thời gian chạy để dò ngược chữ ký theo từng byte một.exp, nbf với đồng hồ serverChốt chặn an ninh số 3 (Hạn sử dụng).Ba chốt chặn này lọc ba mũi tấn công khác biệt: Chốt 3 chặn tráo đổi thuật toán, Chốt 5 chặn sửa đổi văn bản, Chốt 7 chặn xài đồ "ôi thiu". Nếu bạn ngớ ngẩn đem bước 7 lên trước bước 5, bạn tự nộp mạng — vì lúc đó exp chỉ là một con số do hacker tùy ý vẽ ra.
Toán học không có khái niệm thời gian. Chữ ký y424kD... vĩnh viễn là một chữ ký hợp lệ của văn bản đó, dù là hôm nay hay 10 năm nữa. Chuyện "Hết hạn" hoàn toàn là quy ước con người tự đặt ra ở tầng logic (Bước 7). Đây chính là câu trả lời cốt lõi cho việc vì sao JWT không thể bị "thu hồi": Bạn không thể ép một phép toán đúng trở thành một phép toán sai.
Hãy nhìn lại alg. Nó nằm trong thành phần Mực (A). Mà A thì là văn bản hở, client thích sửa sao thì sửa. Nếu một server ngây thơ viết code theo tư duy này:
alg = readHeader(A)
C' = runAlgorithmNamedBy(alg)
Dòng thứ hai là đòn chí mạng: Server để token tự chọn cách nó bị kiểm tra.
Lúc này, kẻ tấn công hoàn toàn nắm đằng chuôi. Hắn khai báo alg: "none" (hiểu nôm na là: "Tôi dùng mực tàng hình"). Khổ thay, chuẩn JWT lại định nghĩa "none" là không cần chữ ký. Server ngốc nghếch đọc được bèn vui vẻ bỏ qua bước đối chiếu chữ ký:
Server ân cần tiếp đón token giả mạo này. Nó không hề bị hack qua bất kỳ lỗ hổng toán học cao siêu nào — nó bị lừa vì ngoan ngoãn làm y hệt lời kẻ trộm bảo.
Còn một biến thể cực độc khác tên là HS/RS confusion. Giả sử hệ thống đang dùng RS256 (Khóa bất đối xứng), việc công bố Public Key ra ngoài là chuyện hiển nhiên. Hacker lém lỉnh bèn đổi header thành HS256 (Khóa đối xứng), rồi xách luôn cái Public Key vừa chôm được đó làm Secret để tự ký. Khi token bay tới server, server đọc thấy yêu cầu dùng HS256, bèn tiện tay vớ luôn cái Public Key của chính mình đem ra làm khóa bí mật đối xứng để tính hàm băm. Kết quả: Khớp hoàn toàn! Một chiếc chìa khóa công cộng vừa bị ép đóng vai khóa bí mật thành công rực rỡ.
const ALLOWED = ['HS256'];
if (!ALLOWED.includes(alg)) reject();
C' = HMAC_SHA256(secret, A + "." + B);
Danh sách mực (alg) được phép dùng do Server chốt cứng từ đầu. Nếu sai loại mực, từ chối ngay. Thuật toán băm ở dòng 3 phải luôn cố định — không hùa theo alg.
Bằng cách này, alg bị giáng cấp xuống thành một thứ để đối chiếu chéo, chứ không còn là lệnh để Server phục tùng. Giờ thì bạn đã hiểu vì sao Bước 3 bắt buộc phải đi trước Bước 4.
Nếu đang dùng thư viện passport-jwt với khóa đối xứng, trông bạn có vẻ an toàn — nhưng đó là nhờ thiết lập ngầm định của thư viện chứ không phải do bạn giỏi. Lời khuyên là hãy ném vào hàm cấu hình dòng chữ algorithms: ['HS256'] tường minh. Đừng để giả định ngầm biến thành "quả bom nổ chậm" vào cái ngày ai đó trong team đổi sang hệ khóa bất đối xứng.
Chữ ký hợp lệ tới đúng khoảnh khắc exp, bất chấp việc user đó đã bấm nút Logout, bị ban nick, hay bị xóa sổ khỏi database. Muốn vá điểm này, các hệ thống thường áp dụng ba chiêu liên hoàn: Đặt exp siêu ngắn (15 phút), nhét token ID vào Blacklist trên Redis mỗi khi user logout (với TTL bằng đúng thời gian sống còn lại), và liên tục tra soát phân quyền trên database. Sự trớ trêu xuất hiện: Dùng JWT để tránh gọi Database/Redis, cuối cùng vẫn phải gọi Redis bù vào. Dù sao thì, gọi Redis vẫn dễ thở hơn gọi Postgres DB liên tục.
Thời điểm hết hạn exp được đo theo chiếc đồng hồ của Server backend chạy code. Nếu máy chủ bị lệch múi giờ hay trôi nhịp 10 phút, token của bạn bỗng nhiên biến thành chết yểu hoặc sống dai bất thường, mà log chẳng hề báo lỗi. Bạn sẽ phải làm quen với việc cấu hình thêm clockTolerance.
Một token cơ bản trong bài đã dài tới 173 ký tự, nặng gấp 5 lần một chuỗi Session ID thông thường. Vì nó bị đính kèm vào phần Header của mọi request, bạn càng nhét nhiều claim vào B (Nội dung), băng thông ứng dụng càng nặng nề. Lời khuyên vàng: Chỉ lưu sub (ID user) và tối đa 1-2 trường thật sự thiết yếu.
Khi Token chính tả tơi bởi giới hạn số 1, người ta đành đẻ ra Token phụ. Khái niệm Access/Refresh token sinh ra từ đó:
| Access Token | Refresh Token | |
|---|---|---|
| Bản chất | JWT tự chứa dữ liệu | Chuỗi ngẫu nhiên lưu trong DB |
| Xác thực bằng | Toán học băm | Truy vấn Cơ sở dữ liệu |
| Trạng thái | Stateless (Không chạm DB) | Stateful (Chạm DB) |
| Khả năng thu hồi | Không | Cực dễ, xóa 1 dòng là xong |
| Tần suất dùng | Gắn vào Mọi Request | Hiếm (Khoảng 15 phút 1 lần) |
Đây là một sự đánh đổi khôn ngoan của thiết kế hệ thống: Thứ gì bị gọi liên tục thì làm cho tối giản siêu tốc, thứ gì hiếm khi gọi thì làm cho uy quyền, rành mạch. Refresh Token cũng nên áp dụng xoay vòng (Rotation) — mang Token cũ tới đổi lấy Token mới thì cái cũ bị thiêu rụi ngay tắp lự. Kẻ trộm nào ôm cái Token cũ về nhà sẽ chỉ nhận được những lời từ chối cay đắng.
SECRET_KEY bé tí trong file .env. Giữ nó cẩn thận nhé!