← Back to all posts
AUTHENTICATION - BACKEND - LEARNING

Hiểu rõ hơn về JWT thay vì chỉ import và cắm đầu dùng

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 dungChữ ký.

Bài toán

Làm sao để tin một văn bản do chính tay client cầm tới?

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ý).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsImVtYWlsIjoibmFtQGV4YW1wbGUuY29tIiwiaWF0IjoxNzg3ODIwMzE1LCJleHAiOjE3ODc4MjEyMTV9.y424kD-Rj_zTwp3Izj8qz4in7cK8CRVjBfs4uO5PqKY
A · header · Mực B · payload · Nội dung C · signature · 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.

Toàn bộ cơ chế trong hai dòng

Chiếc bút thần kỳ của Server

C = HMAC(secret, A + "." + B) → token = A.B.C
KiểmC' = 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:

  • Thứ ném vào hàm băm là chuỗi ASCII đã base64, kể cả dấu chấm ở giữa — không phải object JSON thuần. Nên bên kiểm tra phải dùng đúng chuỗi byte nhận được, tuyệt đối không decode ra rồi encode lại.
  • Không có khái niệm encrypt/decrypt (mã hóa/giải mã) ở đây. Hàm băm là hàm một chiều. Server không "mở" C ra để đọc gì hết — nó tính lại C rồi đem so sánh.
  • C khớp mới chỉ có nghĩa là "văn bản chưa bị sửa", chứ chưa hẳn là "còn giá trị sử dụng". Đây là hai khái niệm khác nhau hoàn toàn.
Mảnh thứ nhất

A — Mực của chữ ký

Nếu bạn decode phần A ra, bạn sẽ được đúng một object bé xíu:

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

algalgorithm — 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:

algHàm tạo ra CKhoá (Chiếc bút)
HS256HMAC-SHA256một secret chung
HS384 / HS512HMAC với SHA-384/512như trên, chữ ký dài hơn
RS256chữ ký số RSA + SHA-256private ký, public kiểm
ES256ECDSA đường cong P-256như RSA, khoá ngắn hơn
nonekhông có — C rỗngnhư 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.

Mảnh thứ hai

B — Nội dung công khai

{"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ự:

ClaimNghĩaThư viện tự xử lý?
expexpiration — mốc hết hạnCó, tự từ chối khi quá hạn
iatissued at — lúc kýCó, dùng để tính exp
nbfnot before — chưa hiệu lực trước mốc này
subsubject — token nói về aiKhông, bạn tự dùng
ississuer — ai phát hànhChỉ khi bạn khai issuer lúc verify
audaudience — dành cho service nàoChỉ khi bạn khai audience
jtiJWT ID — định danh riêng từng tokenKhô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.

Mảnh thứ ba

C — Chữ ký

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ự.

raw : cb8db8903f918ffcd3c29dc8ce3f2acf88a7edc2bc09156305fb38b8ee4fa8a6 b64url : y424kD-Rj_zTwp3Izj8qz4in7cK8CRVjBfs4uO5PqKY

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.

Ba tính chất làm nên tất cả

  • Tính tất định: Cùng một đầu vào (Mực + Nội dung), dùng cùng một chiếc bút (Secret) thì luôn ra đúng cùng một Chữ ký. Nhờ vậy Server mới có thể "tính lại rồi so" một cách độc lập.
  • Hiệu ứng cánh bướm: Chỉ cần sửa 1 bit (ví dụ 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ẹ.
  • Tính một chiều (Không thể đảo ngược): Bạn không thể lấy chữ ký C để suy ngược lại chiếc bút Secret.

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àoKích thướcSHA-256 cho ra
Một ký tự1 byte4048c449… 32 byte
Một câu ngắn15 byte4d165189… 32 byte
Một cuốn sách1 000 000 byte31a87ed2… 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ò.

Thử tay

Phòng thí nghiệm JWT

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.

HMAC-SHA256chạy cục bộ

Kiểm tra

Phía phát hành

Bảy bước vung bút ký

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:

  1. Nhận claim của bạnĐúng hai trường: sub, email. Chưa có thời gian hay thuật toán gì ở đây.
  2. Tự đắp thêm iatexpiat = giây hiện tại, exp = iat + expiresIn.
  3. Định hình Mực (Header)Vì Secret là chuỗi đối xứng, thư viện tự chốt algHS256.
  4. Serialize JSON, base64urlGiữ nguyên thứ tự key, bỏ khoảng trắng. Đảo thứ tự object là đổi chuỗi byte, mà đổi chuỗi byte là hỏng luôn chữ ký.
  5. Nối A + "." + BTạo ra một chuỗi dài 129 byte làm phôi gốc.
  6. Vung bút ký (HMAC bằng Secret → C)Nơi duy nhất trong toàn bộ quy trình mà Secret xuất hiện. Bốn bước trước ai cũng tự làm được.
  7. Hoàn thiện A.B.C, trả về, quên luônServer không lưu vào DB, không đẩy vào Redis. Chuẩn stateless (phi trạng thái).

Một hệ quả nhỏ mà thú vị

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.

Phía tiếp nhận

Bảy bước kiểm tra — Thứ tự là bất khả xâm phạm

  1. Cắt chuỗi bằng dấu chấmKhông ra đúng 3 khúc? Báo lỗi jwt malformed ngay.
  2. Giải mã chỉ A, đọc algPhần B vẫn nằm im lìm nguyên khối, chưa ai đụng vào.
  3. Kiểm tra: 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ả.
  4. Tính lại C' = HMAC(secret, A + "." + B)Dùng đúng chuỗi byte nhận được từ client.
  5. So 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.
  6. Khui nội dung: Decode BTới tận bước này mới được phép nhìn vào nội dung. Không tin một byte nào cho đến khi chữ ký đã được duyệt.
  7. Kiểm 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.

Lỗ hổng kinh điển

Đừng hỏi văn bản xem nên xác minh nó bằng cách nào

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ý:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOjEsImVtYWlsIjoibmFtQGV4YW1wbGUuY29tIn0.
header: {"alg":"none"}payload: sub = 1 (tự phong admin)chữ ký: rỗng

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ỡ.

Cách chống cho cả hai thảm họa

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.

Cái giá phải trả

Ba mặt trái của sự tự do (Stateless)

1. Không thể thu hồi ngay lập tức

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.

2. Con tin của cái đồng hồ

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.

3. Béo phì cục bộ

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.

Đó là lý do Refresh Token ra đời

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 TokenRefresh Token
Bản chấtJWT tự chứa dữ liệuChuỗi ngẫu nhiên lưu trong DB
Xác thực bằngToán học bămTruy vấn Cơ sở dữ liệu
Trạng tháiStateless (Không chạm DB)Stateful (Chạm DB)
Khả năng thu hồiKhôngCực dễ, xóa 1 dòng là xong
Tần suất dùngGắn vào Mọi RequestHiế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.

Đọng lại

Sáu câu thần chú nhập môn

  • JWT chỉ là 3 chuỗi văn bản nối lại; 2 chuỗi đầu tiên trống hoác, ai cũng đọc được.
  • Secret không phải hộp sắt để khóa nội dung, Secret là con dấu để chống sửa nội dung.
  • Verify (Xác minh) không phải là dùng chìa khóa mở cái ổ khóa ra. Verify là tự tay viết lại một văn bản y hệt rồi so nét chữ.
  • Chữ ký khớp chỉ khẳng định: "Đúng là văn bản tôi viết". Nó không khẳng định: "Tờ giấy này chưa hết hạn".
  • Đừng để Token tự chọn loại mực nó dùng; hãy tự chốt cứng chuẩn thuật toán từ ban đầu.
  • Sự sụp đổ hay vững chãi của cả một hệ thống Auth đồ sộ có khi chỉ nằm gói gọn ở chuỗi SECRET_KEY bé tí trong file .env. Giữ nó cẩn thận nhé!