2.11 - Ca chẩn đoán: đăng nhập được nhưng mọi trang báo 401
Tóm tắt: Một tình huống rất hay gặp khi mới làm backend: người dùng đăng nhập thành công nhưng mọi trang sau đó đều báo 401 Unauthorized. Bài này lần theo request qua đúng năm chặng — trình duyệt, DNS, HTTPS, backend, database — và cho thấy cách loại trừ dần từng chặng thay vì đoán. Nguyên nhân cuối cùng hoá ra không nằm ở code xác thực chút nào, mà ở một thứ ít ai nghĩ tới khi mới học: hệ thống có hai máy chủ API, và đồng hồ của một máy chạy nhanh 25 phút, khiến token do máy kia vừa cấp đã bị coi là hết hạn. Điều đáng học nhất là phương pháp: mỗi chặng đều có một cách kiểm tra riêng, và kiểm tra theo thứ tự tiết kiệm rất nhiều thời gian.
Mục tiêu bài học
Sau bài này bạn có thể:
- Lần theo một request qua năm chặng của hệ thống web.
- Dùng công cụ phù hợp để kiểm tra từng chặng.
- Loại trừ dần thay vì đoán nguyên nhân.
- Giải thích vì sao lệch đồng hồ giữa máy phát token và máy kiểm tra token làm hỏng đăng nhập, theo cả hai chiều.
- Nhận ra những nguyên nhân nằm ngoài code.
Cần biết trước: 2.4 — HTTP/HTTPS (401, header) và 2.7 — Authentication và Authorization (JWT, exp). Bài 3 dùng lại cách tạo project và gọi curl như CrmLab ở mục 2.3.6.
Nội dung bài học
2.11.1 — Triệu chứng
Người dùng: "Tôi đăng nhập được, nhưng bấm vào bất kỳ menu nào
đều bị đẩy về trang đăng nhập."
Kiểm tra ban đầu:
- POST /api/auth/login -> 200 OK, trả về token
- GET /api/leads -> 401 Unauthorized
- Xảy ra với MỌI người dùng
- Mới bắt đầu sáng nay, hôm qua vẫn bình thườngHệ thống chạy hai máy chủ API giống hệt nhau, tạm gọi là api-a và api-b, đứng sau một bộ cân bằng tải chia đều request cho hai máy.
Hai thông tin trong phần cuối đã thu hẹp phạm vi rất nhiều: mọi người dùng nghĩa là không phải vấn đề tài khoản cá nhân; mới bắt đầu hôm nay nghĩa là có gì đó vừa thay đổi.
2.11.2 — Năm chặng của một request
Nguyên tắc: kiểm tra từ ngoài vào trong, vì mỗi chặng loại trừ được là bớt một phần phạm vi tìm kiếm.
2.11.3 — Chặng 1 và 2: trình duyệt, DNS, HTTPS
Mở DevTools, tab Network, xem request GET /api/leads:
Request URL: https://crm.company.com/api/leads
Status Code: 401 Unauthorized
Request Headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... <- token CÓ gửi điHai kết luận ngay:
- DNS và HTTPS không phải vấn đề — request đã tới server và nhận được phản hồi. Nếu DNS sai, bạn sẽ thấy lỗi
ERR_NAME_NOT_RESOLVED; nếu chứng chỉ sai,ERR_CERT_*. - Frontend có gửi token — nếu thiếu header
Authorization, vấn đề nằm ở frontend, không phải backend.
Đây là bước kiểm tra nhanh nhất và loại trừ được hai chặng. Luôn bắt đầu từ đây (bài 2.4).
2.11.4 — Chặng 3: request có tới backend không?
Chép token từ DevTools vào biến TOKEN, rồi gửi lại cùng request bằng curl vài lần, bỏ qua trình duyệt:
for i in 1 2 3 4; do
curl -s -o /dev/null -w "%{http_code}\n" https://crm.company.com/api/leads \
-H "Authorization: Bearer $TOKEN"
done200
401
200
401Cùng một token, cùng một request, lúc được lúc không. Đây là manh mối quyết định thứ nhất: có một máy nhận token, một máy từ chối. Xem kỹ một lần bị từ chối:
curl -i https://crm.company.com/api/leads -H "Authorization: Bearer $TOKEN"HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at '09/24/2026 02:03:11'"Manh mối thứ hai: header WWW-Authenticate nói rõ lý do — token đã hết hạn. Nhưng token này vừa được cấp 30 giây trước.
Dữ liệu trong ca này là minh họa, nhưng định dạng dòng WWW-Authenticate ở trên là thông báo thật của ASP.NET Core 10; Bài 3 tái hiện nó trên máy bạn.
curl hữu ích ở đây vì nó loại bỏ mọi thứ của trình duyệt: cache, cookie, extension, CORS. Nếu curl cho kết quả khác trình duyệt thì vấn đề nằm ở phía trình duyệt.
2.11.5 — Chặng 4: backend xử lý thế nào
JWT có ba phần ngăn bởi dấu chấm; phần giữa là payload, mã hoá Base64URL (bài 2.7). Token trong ca này:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJpYXQiOjE3OTAyMTQ0OTEsIm5iZiI6MTc5MDIxNDQ5MSwiZXhwIjoxNzkwMjE1MzkxfQ.bRcRgCFPoZEP5alV1t03SY0tVfFmn910mNioYL9Xf-8Giải mã phần giữa (thêm hai dấu = để độ dài chia hết cho 4):
echo "eyJzdWIiOiIxMjMiLCJpYXQiOjE3OTAyMTQ0OTEsIm5iZiI6MTc5MDIxNDQ5MSwiZXhwIjoxNzkwMjE1MzkxfQ==" | base64 -d{"sub":"123","iat":1790214491,"nbf":1790214491,"exp":1790215391}Ba mốc thời gian, tính bằng giây từ 1970-01-01 UTC: iat (lúc cấp), nbf (không hợp lệ trước lúc này), exp (hết hạn). Đổi sang giờ đọc được:
date -u -d @1790214491 # iat
date -u -d @1790215391 # expThu Sep 24 01:48:11 UTC 2026
Thu Sep 24 02:03:11 UTC 2026Token cấp lúc 01:48:11, sống 15 phút, hết hạn lúc 02:03:11. Mới qua 30 giây, nó còn 14 phút nữa mới hết hạn. Vậy máy nào nói nó đã hết hạn? Hỏi giờ của từng máy:
api-a$ date -u
Thu Sep 24 01:48:43 UTC 2026
api-b$ date -u
Thu Sep 24 02:13:44 UTC 2026 <-- nhanh hơn 25 phútapi-b$ timedatectl status Local time: Thu 2026-09-24 02:13:46 UTC
Time zone: UTC (UTC, +0000)
System clock synchronized: no <-- KHÔNG đồng bộ
NTP service: inactive <-- NTP ĐÃ TẮTNguyên nhân gốc: đợt cập nhật hệ điều hành đêm qua tắt dịch vụ đồng bộ giờ NTP (Network Time Protocol, giao thức máy tính dùng để lấy giờ chuẩn từ máy chủ thời gian) trên api-b. Máy khởi động lại với giờ lấy từ đồng hồ phần cứng vốn đang sai 25 phút, và không còn NTP để kéo về giờ đúng.
2.11.6 — Vì sao lệch đồng hồ gây ra chuyện này
JWT chứa exp và nbf là mốc thời gian tuyệt đối. Máy phát token tính chúng theo đồng hồ của nó; máy kiểm tra token so chúng với đồng hồ của mình. Cùng một máy làm cả hai việc thì lệch đồng hồ không gây chuyện, vì máy đó lệch nhất quán với chính nó. Chuyện chỉ xảy ra khi máy phát và máy kiểm là hai máy khác nhau: hai thể hiện sau bộ cân bằng tải như ca này, hoặc một máy chủ xác thực riêng và một API.
Trong ca này có hai chiều hỏng:
Đăng nhập rơi vào api-a (giờ đúng):
token: iat 01:48:11, exp 02:03:11
request sau rơi vào api-b, đồng hồ chỉ 02:13:44
-> 02:13:44 đã quá exp cộng 5 phút dung sai (02:08:11)
-> "The token expired at ..."
Đăng nhập rơi vào api-b (nhanh 25 phút):
token: iat 02:13:xx, nbf 02:13:xx
request sau rơi vào api-a, đồng hồ chỉ 01:48:xx
-> chưa tới nbf, kể cả trừ 5 phút dung sai
-> "The token is not valid before ..."Dung sai 5 phút đó là ClockSkew, giá trị mặc định của thư viện kiểm tra JWT trong ASP.NET Core. Lệch dưới 5 phút thì không ai nhận ra; lệch 25 phút thì vượt xa.
Vì sao "mọi người dùng" bị ảnh hưởng, dù chỉ một máy hỏng. Bộ cân bằng tải chia đều, nên mỗi request có khoảng một nửa khả năng rơi vào máy "sai chiều". Một trang CRM gọi năm, sáu API khi mở; chỉ cần một lời gọi nhận 401 là frontend đẩy người dùng về trang đăng nhập. Gần như không ai mở trọn được một trang.
Sửa, trên api-b:
sudo timedatectl set-ntp true
sudo systemctl restart systemd-timesyncd
timedatectl status | grep synchronized # xác nhận: yesVấn đề biến mất ngay khi đồng hồ được kéo về, không cần deploy lại gì.
Phòng ngừa: thêm cảnh báo khi đồng hồ máy chủ không đồng bộ, ví dụ một health check (bài 8.9) đọc trạng thái NTP, hoặc ghi cảnh báo khi gặp token có iat nằm trong tương lai như Bài 3. Lần sau hệ thống tự báo thay vì chờ người dùng phát hiện.
2.11.7 — Bảng công cụ theo chặng
| Chặng | Câu hỏi | Công cụ |
|---|---|---|
| Trình duyệt | Request có được gửi đúng không? | DevTools → Network |
| DNS | Tên miền phân giải ra IP nào? | nslookup, dig |
| HTTPS | Chứng chỉ có hợp lệ không? | Ổ khoá trên thanh địa chỉ, openssl s_client |
| HTTP | Server trả về gì, kèm header nào? | curl -i |
| Backend | Xử lý tới đâu thì lỗi? | Log ứng dụng, breakpoint |
| Database | Truy vấn có chạy không? | Log truy vấn, profiler |
Thứ tự quan trọng. Bắt đầu từ chặng gần người dùng nhất và đi vào trong. Nhảy thẳng vào đọc code backend khi chưa biết request có tới đó không là cách tốn thời gian nhất.
2.11.8 — Danh sách rà soát khi chẩn đoán
Danh sách rà soát khi chẩn đoán lỗi web
- Đã xem DevTools Network trước khi đọc code.
- Đã xác nhận request có tới server và có kèm header cần thiết.
- Đã dùng curl để loại trừ yếu tố trình duyệt, và gọi lặp vài lần để xem lỗi có xảy ra mọi lần không.
- Đã đọc kỹ thông điệp lỗi trong header phản hồi, không chỉ mã trạng thái.
- Đã kiểm tra những thứ ngoài code: đồng hồ, biến môi trường, chứng chỉ.
- Đã hỏi cái gì vừa thay đổi gần đây nhất.
- Kiểm tra theo thứ tự từ ngoài vào trong, không nhảy cóc.
- Sau khi sửa, đã thêm cảnh báo để lần sau hệ thống tự báo.
Bài tập áp dụng
Bài 1 — Lần theo một request
Mở DevTools trên một trang bất kỳ và ghi lại đầy đủ năm chặng của một request API.
Tiêu chí hoàn thành: bạn có số liệu cho từng chặng, và chỉ ra được chặng nào chiếm nhiều thời gian nhất — thường không phải chặng bạn đoán.
Gợi ý và lời giải — Bài 1
Gợi ý. Tab Network → bấm vào một request → tab Timing. Nó chia sẵn thời gian theo từng giai đoạn.
Lời giải — đọc bảng Timing (số liệu minh họa của một request tới một API giả định; máy và mạng của bạn sẽ cho số khác):
Request: GET https://api.crm.vn/api/leads?page=1
Queueing 0,42 ms
Stalled 1,18 ms
DNS Lookup 24,00 ms <- chặng 2
Initial connection 48,00 ms <- chặng 2 (TCP)
SSL 31,00 ms <- chặng 2 (TLS, nằm trong Initial connection)
Request sent 0,21 ms <- chặng 3
Waiting (TTFB) 186,00 ms <- chặng 4: MÁY CHỦ XỬ LÝ
Content Download 12,00 ms <- chặng 5
---------
Total 271,81 msÁnh xạ sang năm chặng ở mục 2.11.2:
| Chặng | Trong DevTools | Thời gian | Tỉ lệ |
|---|---|---|---|
| 1. Trình duyệt chuẩn bị | Queueing + Stalled | 1,6 ms | 0,6% |
| 2. DNS + TCP + TLS | DNS + Initial connection | 72,0 ms | 26,5% |
| 3. Gửi request | Request sent | 0,2 ms | 0,1% |
| 4. Máy chủ xử lý | Waiting (TTFB) | 186,0 ms | 68,4% |
| 5. Nhận phản hồi | Content Download | 12,0 ms | 4,4% |
Ba điều bảng này nói ra:
1. Chặng 4 chiếm gần 70% — và đó là chặng duy nhất bạn kiểm soát được.
Waiting (TTFB) = thời gian từ lúc request tới máy chủ cho tới byte đầu tiên về
Nó gồm: định tuyến, middleware, truy vấn database, xử lý nghiệp vụ
-> đây là chỗ mọi kỹ thuật tối ưu backend tác động vào2. Chặng 2 tốn 72 ms — nhưng chỉ ở request ĐẦU TIÊN. Gọi lại cùng endpoint ngay sau đó, bảng Timing thường trông như sau:
DNS Lookup 0 ms <- đã có trong bộ nhớ đệm DNS
Initial connection 0 ms <- kết nối được dùng lại (keep-alive)
Waiting (TTFB) 182 ms
Content Download 11 ms
-------
Total 193 ms <- giảm 79 ms so với lần đầuĐây là lý do đo một request đơn lẻ dễ gây hiểu lầm: request đầu tiên gánh toàn bộ chi phí thiết lập, những request sau thì không.
3. Và 12 ms tải nội dung cho một phản hồi 4,2 kB là chậm hơn bạn tưởng.
4,2 kB trên một kết nối bình thường: dưới 1 ms
12 ms nghĩa là phản hồi đến thành nhiều gói, hoặc mạng có độ trễ cao
-> nếu phản hồi là 400 kB thay vì 4 kB, con số này thành hàng trăm ms
-> và đó là lúc projection (chỉ lấy cột cần) trở nên quan trọngBốn chặng đầu đo được mà không cần trình duyệt:
curl -s -o /dev/null -w "\
dns: %{time_namelookup}s\n\
tcp: %{time_connect}s\n\
tls: %{time_appconnect}s\n\
ttfb: %{time_starttransfer}s\n\
tong: %{time_total}s\n" \
https://api.crm.vn/api/leads?page=1dns: 0.024301s
tcp: 0.072118s
tls: 0.103447s
ttfb: 0.289512s
tong: 0.301776sLưu ý: các con số này CỘNG DỒN, không phải thời lượng từng phần.
thời gian TLS riêng = time_appconnect − time_connect = 31 ms
thời gian máy chủ = time_starttransfer − time_appconnect = 186 msVà một chặng mà DevTools không hiện ra, nhưng thường là nguyên nhân thật:
Waiting (TTFB) = 186 ms
Nhưng 186 ms đó gồm gì?
- middleware xác thực: ?
- truy vấn database: ?
- tuần tự hoá JSON: ?
DevTools không biết. Nó chỉ thấy "máy chủ mất 186 ms".Để chia nhỏ chặng 4, cần đo từ phía máy chủ bằng header Server-Timing. Đoạn middleware và hai lỗi hay gặp khi viết nó nằm ở lời giải Bài 1 của bài 2.1: header phải gắn trong Response.OnStarting, vì gắn sau await next() thì response đã bắt đầu gửi đi và ASP.NET Core ném InvalidOperationException: Headers are read-only, response has already started.
Header Server-Timing hiện THẲNG trong tab Timing của DevTools
-> nối được số liệu phía máy chủ với số liệu phía trình duyệt
-> và đó là cách nhanh nhất để biết 186 ms đi đâuBài 2 — Giải mã JWT
Lấy một token thật, tách phần payload và giải mã base64. Đọc exp và iat rồi đổi sang thời gian.
Tiêu chí hoàn thành: bạn đọc được nội dung token mà không cần khoá bí mật, và rút ra được điều đó có nghĩa gì với dữ liệu bạn đặt trong token.
Gợi ý và lời giải — Bài 2
Gợi ý. JWT có ba phần ngăn bởi dấu chấm. Hai phần đầu chỉ là base64url — ai cũng đọc được.
Lời giải — một token mẫu:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4ODQyIiwibmFtZSI6Ik5ndXllbiBWYW4gQW4iLCJ0ZW5hbnQiOiJtZWdhY29ycCIsImlhdCI6MTc5MDAwMDAwMCwibmJmIjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDA5MDB9.xsch3d9zSYqoCf_cI5kCcATAJkq7XA-7RQ3WHQuq4ZkDùng hàm jwt_payload ở Bài 1 của bài 2.7, nó đổi Base64URL về Base64 và thêm phần đệm = trước khi giải:
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4ODQyIiwibmFtZSI6Ik5ndXllbiBWYW4gQW4iLCJ0ZW5hbnQiOiJtZWdhY29ycCIsImlhdCI6MTc5MDAwMDAwMCwibmJmIjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDA5MDB9.xsch3d9zSYqoCf_cI5kCcATAJkq7XA-7RQ3WHQuq4Zk'
jwt_payload "$TOKEN"{"sub":"8842","name":"Nguyen Van An","tenant":"megacorp","iat":1790000000,"nbf":1790000000,"exp":1790000900}date -u -d @1790000000 +%FT%TZ # iat và nbf
date -u -d @1790000900 +%FT%TZ # exp2026-09-21T14:13:20Z
2026-09-21T14:28:20ZThời hạn sống: 900 giây = 15 phút. Phần header giải mã cùng cách:
{"alg":"HS256","typ":"JWT"}Điều này có nghĩa gì — ba hệ quả:
1. JWT KHÔNG mã hoá. Nó chỉ được KÝ.
Chữ ký đảm bảo: nội dung KHÔNG BỊ SỬA
Chữ ký KHÔNG đảm bảo: nội dung được giữ kín
-> bất kỳ ai cầm token đều đọc được mọi claim trong đó
-> kể cả JavaScript trong trình duyệt, kể cả một proxy trung gian2. Vì vậy, không đặt dữ liệu nhạy cảm vào token.
| Đặt được | Không đặt |
|---|---|
| Id người dùng | Số căn cước, số thẻ |
| Tên hiển thị | Số điện thoại, địa chỉ |
| Mã tenant | Lương, hạn mức tín dụng |
| Vai trò | Mật khẩu hoặc băm mật khẩu |
| Quyền hạn ở mức thô | Ghi chú nội bộ về khách hàng |
Câu hỏi phân loại: "Nếu dòng này hiện trên màn hình người dùng,
có vấn đề gì không?"3. Và sửa được không? Không — nhưng thử thì thấy rõ vì sao.
Sửa "megacorp" thành "admin" trong payload rồi ghép lại ba phần
-> máy chủ tính lại chữ ký từ header + payload MỚI bằng khoá bí mật
-> ra một chữ ký khác với chữ ký gửi lên
-> 401 Unauthorized
Muốn sửa mà vẫn hợp lệ thì phải có KHOÁ BÍ MẬT.Ba trường iat, nbf, exp và vai trò của từng cái:
| Trường | Nghĩa | Máy chủ kiểm tra |
|---|---|---|
iat | Lúc phát hành | Thường chỉ để ghi nhận, và để thu hồi theo mốc thời gian |
nbf | Không hợp lệ trước thời điểm này | Token chưa tới giờ dùng → từ chối |
exp | Hết hạn sau thời điểm này | Token quá hạn → từ chối |
Cả ba đều là giây tính từ 1970-01-01 UTC (Unix epoch).
Chúng KHÔNG có múi giờ — luôn là UTC.Vì sao thời hạn 15 phút, không phải 24 giờ:
JWT không thu hồi được. Đã phát ra là hợp lệ cho tới khi hết hạn.
Người dùng bị khoá tài khoản lúc 14:15
-> token phát lúc 14:13, hết hạn 14:28
-> họ vẫn truy cập được 13 phút nữa
Thời hạn càng dài, cửa sổ đó càng rộng.Cách thường dùng:
access token 15 phút -> cửa sổ rủi ro nhỏ
refresh token 7–30 ngày, LƯU TRONG DATABASE -> thu hồi được ngayRefresh token lưu trong database là chỗ bù lại điểm yếu của JWT: nó tra cứu được, nên khoá tài khoản có hiệu lực ngay ở lần làm mới token kế tiếp.
Dán vào jwt.io cũng xem được, NHƯNG:
-> đừng dán token thật của production vào một trang web
-> nó là thông tin đăng nhập đang còn hiệu lựcBài 3 — Tái hiện lệch đồng hồ
Dựng một API nhỏ có hai vai: một endpoint phát token giả lập máy có đồng hồ lệch một số phút tuỳ chọn, và một endpoint cần token để kiểm tra theo đồng hồ thật của máy bạn. Thử lệch 0, 3, −25 và +25 phút.
Tiêu chí hoàn thành: bạn thấy lỗi xảy ra theo hai chiều khác nhau tuỳ đồng hồ máy phát nhanh hay chậm, biết vì sao lệch 3 phút vẫn qua, và có một cách phát hiện lệch đồng hồ trước khi người dùng gặp lỗi.
Gợi ý và lời giải — Bài 3
Gợi ý. Không cần hai máy thật. Lệch đồng hồ chỉ có nghĩa là hai bên tính giờ khác nhau, nên chỉ cần cho máy phát cộng thêm vài phút khi tính iat, nbf, exp. Nghĩ xem máy phát nhanh hay chậm thì lỗi rơi vào trường nào.
Lời giải — dựng thí nghiệm:
dotnet new web -n ClockSkewLab
cd ClockSkewLab
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerThay Program.cs (bạn chưa cần hiểu hết phần cấu hình JWT, bài 10.2 dạy kỹ):
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
// Khoá ký dùng chung giữa máy phát và máy kiểm (chỉ để thực hành)
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("khoa-bi-mat-chi-dung-de-thuc-hanh-it-nhat-32-byte"));
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
IssuerSigningKey = key,
ClockSkew = TimeSpan.FromMinutes(5) // giá trị mặc định
};
o.Events = new JwtBearerEvents
{
OnTokenValidated = ctx =>
{
// iat nằm "trong tương lai" so với đồng hồ máy này
// -> máy phát token đang chạy nhanh hơn máy này
if (ctx.SecurityToken is JsonWebToken jwt)
{
var aheadSeconds = (jwt.IssuedAt - DateTime.UtcNow).TotalSeconds;
if (aheadSeconds > 30)
{
ctx.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>()
.LogWarning("Token phát trước giờ máy này {Seconds:F0} giây, nghi lệch đồng hồ", aheadSeconds);
}
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Giả lập một máy phát token có đồng hồ lệch issuerSkewMinutes phút so với máy kiểm
app.MapPost("/login", (int issuerSkewMinutes) =>
{
var issuerNow = DateTime.UtcNow.AddMinutes(issuerSkewMinutes);
var token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity([new Claim("sub", "123")]),
IssuedAt = issuerNow,
NotBefore = issuerNow,
Expires = issuerNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
});
return Results.Ok(new { accessToken = token });
});
app.MapGet("/api/leads", () => Results.Ok(new[] { "Lead 1", "Lead 2" }))
.RequireAuthorization();
app.Run();Chạy dotnet run --urls http://localhost:5299, rồi ở terminal khác:
for s in 0 3 -25 25; do
T=$(curl -s -X POST "http://localhost:5299/login?issuerSkewMinutes=$s" \
| sed 's/.*"accessToken":"\([^"]*\)".*/\1/')
echo "== issuerSkewMinutes=$s"
curl -s -i http://localhost:5299/api/leads -H "Authorization: Bearer $T" \
| grep -E "HTTP/|WWW-Auth|^\["
done
date -uKết quả chạy thật trên .NET 10:
== issuerSkewMinutes=0
HTTP/1.1 200 OK
["Lead 1","Lead 2"]
== issuerSkewMinutes=3
HTTP/1.1 200 OK
["Lead 1","Lead 2"]
== issuerSkewMinutes=-25
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at '10/17/2026 14:38:28'"
== issuerSkewMinutes=25
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The token is not valid before '10/17/2026 15:13:28'"
Sat Oct 17 14:48:29 UTC 2026Và log của API, ở lần lệch 3 phút:
warn: Program[0]
Token phát trước giờ máy này 179 giây, nghi lệch đồng hồĐọc kết quả:
| Máy phát so với máy kiểm | Kết quả | Thông điệp |
|---|---|---|
| Đúng giờ | 200 | |
| Nhanh 3 phút | 200, kèm cảnh báo trong log | Lệch nằm trong ClockSkew 5 phút |
| Chậm 25 phút | 401 | The token expired at ... |
| Nhanh 25 phút | 401 | The token is not valid before ... |
Hai thông điệp lỗi này là manh mối quan trọng nhất:
"The token expired at ..." -> máy kiểm tra NHANH hơn máy phát
"The token is not valid before ..." -> máy kiểm tra CHẬM hơn máy phátVà dấu hiệu chung cho cả hai: thời gian trong thông điệp lỗi
KHÔNG khớp với thời gian thật lúc bạn gặp lỗi.
Ở trên, lúc chạy là 14:48 UTC:
-> "hết hạn lúc 14:38" với một token vừa cấp, hoặc
-> "chưa hợp lệ trước 15:13"
nghĩa là có một đồng hồ đang sai.Vì sao ClockSkew mặc định của .NET là 5 phút:
Trong hệ phân tán, đồng hồ các máy KHÔNG BAO GIỜ khớp tuyệt đối.
NTP giữ sai lệch ở mức mili giây tới vài chục mili giây trong điều kiện tốt,
nhưng máy ảo bị tạm dừng, máy vừa khởi động, hoặc NTP hỏng có thể lệch nhiều hơn.
5 phút là khoảng đệm đủ rộng để chịu được lệch thực tế,
đủ hẹp để không làm mất ý nghĩa của thời hạn token.Với lệch 25 phút: 5 phút đệm không đủ -> lỗi
Với lệch 3 phút: đệm hấp thụ hết -> người dùng không nhận ra có vấn đềBa cấu hình liên quan, và cách chọn:
| Cấu hình | Khi nào |
|---|---|
ClockSkew = TimeSpan.Zero | Khi mọi máy chắc chắn đồng bộ NTP, và bạn muốn thời hạn chính xác |
ClockSkew = 5 phút (mặc định) | Phần lớn trường hợp |
ClockSkew lớn hơn 5 phút | Gần như luôn là dấu hiệu che giấu một vấn đề về đồng hồ |
Đặt ClockSkew = Zero với access token 15 phút:
một máy lệch 30 giây -> token hết hạn sớm 30 giây so với dự kiến
-> người dùng gặp lỗi ngẫu nhiên, khó tái hiệnCách phòng đúng: sửa đồng hồ, không nới ClockSkew. Trên Linux:
sudo timedatectl set-ntp true
timedatectl statusSystem clock synchronized: yes
NTP service: activeTrong Kubernetes, các container dùng chung đồng hồ của máy chủ (node) chạy chúng, nên vấn đề thường nằm ở node, không ở container.
Cách phát hiện sớm, trước khi người dùng báo lỗi — chính đoạn OnTokenValidated trong code trên:
iat của token nằm TRONG TƯƠNG LAI so với đồng hồ máy này
-> máy phát token đang chạy nhanh hơn máy này
-> ghi cảnh báo, kể cả khi token vẫn được chấp nhậnĐoạn này chỉ cảnh báo khi iat vượt quá giờ hiện tại hơn 30 giây. Đừng so tuổi token (giờ hiện tại − iat) với một ngưỡng: một token 15 phút sau phút đầu tiên đã có tuổi hơn 60 giây, nên cách đó cảnh báo giả với gần như mọi request. Cảnh báo này cũng chỉ bắt được một chiều (máy phát nhanh hơn máy kiểm); để bắt cả hai chiều, mỗi máy cần tự theo dõi trạng thái đồng bộ NTP của mình.
Cảnh báo xuất hiện khi lệch mới vài chục giây, TRƯỚC khi vượt ClockSkew
-> bạn sửa NTP trong lúc hệ thống vẫn chạy bình thường,
thay vì lúc mọi người đang không đăng nhập đượcVà đây là ý chung của mục 2.11.6: lệch đồng hồ không gây lỗi từ từ — nó hoạt động hoàn hảo cho tới khi vượt ngưỡng, rồi hỏng hoàn toàn. Một cảnh báo ở ngưỡng thấp hơn là cách duy nhất để thấy nó đang tới.
Muốn thử với hai máy thật, dựng hai máy ảo, tắt NTP trên một máy bằng sudo timedatectl set-ntp false rồi chỉnh giờ bằng sudo date -s "+25 minutes". Kết quả giống hệt bảng trên.
Tự kiểm tra
Vì sao nên kiểm tra từ ngoài vào trong?
Vì mỗi chặng loại trừ được là bớt một phần phạm vi tìm kiếm. Nhảy thẳng vào đọc code backend khi chưa biết request có tới đó không là cách tốn thời gian nhất.
DevTools Network cho biết những gì trong ca này?
Rằng DNS và HTTPS không phải vấn đề vì request đã tới server và nhận được phản hồi, và rằng frontend có gửi header Authorization. Hai kết luận đó loại trừ được hai chặng đầu ngay lập tức.
Gọi lặp cùng một request bằng curl cho 200 rồi 401 xen kẽ. Điều đó gợi ý gì?
Rằng phía sau có nhiều máy chủ và chúng xử lý khác nhau: một máy chấp nhận token, một máy từ chối. Vấn đề nằm ở một máy cụ thể (cấu hình, đồng hồ, phiên bản code), không nằm ở token hay ở trình duyệt.
Vì sao lệch đồng hồ làm token vừa cấp đã hết hạn?
Vì JWT chứa exp là mốc thời gian tuyệt đối do máy phát tính theo đồng hồ của nó, còn máy kiểm tra so mốc đó với đồng hồ của mình. Khi hai việc nằm trên hai máy khác nhau và máy kiểm tra chạy nhanh hơn quá khoảng dung sai ClockSkew (mặc định 5 phút), token vừa cấp đã bị coi là hết hạn. Nếu máy kiểm tra chạy chậm hơn thì lỗi ngược lại: token bị coi là chưa tới giờ hợp lệ.
Thông tin nào trong triệu chứng ban đầu giúp thu hẹp phạm vi nhất?
Rằng lỗi xảy ra với mọi người dùng, nên không phải vấn đề tài khoản cá nhân, và rằng nó mới bắt đầu hôm nay, nên có gì đó vừa thay đổi. Hai thông tin đó hướng ngay tới thay đổi hệ thống chứ không phải bug code.
Nên làm gì sau khi sửa xong sự cố kiểu này?
Thêm cảnh báo khi đồng hồ máy chủ không đồng bộ, ví dụ health check đọc trạng thái NTP, hoặc ghi cảnh báo khi gặp token có iat nằm trong tương lai. Mỗi sự cố nên để lại một cảnh báo mới.
Kết luận
Ba điều đáng nhớ nhất:
- Kiểm tra từ ngoài vào trong. DevTools trước, code sau.
- Đọc kỹ thông điệp lỗi, không chỉ mã trạng thái — nó thường nói thẳng nguyên nhân.
- Nguyên nhân có thể nằm ngoài code: đồng hồ, biến môi trường, chứng chỉ. Lỗi xen kẽ lúc được lúc không thường chỉ tới một máy trong nhiều máy.
Tham khảo
- HTTP trên MDN
- HTTP status codes
- RFC 7519 — JSON Web Token — định nghĩa
iat,nbf,exp - Authentication in ASP.NET Core
Điều hướng
- Bài trước: 2.10 — Đào sâu: cache HTTP, container, HTTP/2 và HTTP/3
- Bài tiếp theo: 2.12 — CORS: đọc lỗi, preflight và thứ tự middleware
- Về module: Trang mục lục