Skip to content

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ường

Hệ 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 đi

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

bash
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"
done
text
200
401
200
401

Cù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:

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

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJpYXQiOjE3OTAyMTQ0OTEsIm5iZiI6MTc5MDIxNDQ5MSwiZXhwIjoxNzkwMjE1MzkxfQ.bRcRgCFPoZEP5alV1t03SY0tVfFmn910mNioYL9Xf-8

Giải mã phần giữa (thêm hai dấu = để độ dài chia hết cho 4):

bash
echo "eyJzdWIiOiIxMjMiLCJpYXQiOjE3OTAyMTQ0OTEsIm5iZiI6MTc5MDIxNDQ5MSwiZXhwIjoxNzkwMjE1MzkxfQ==" | base64 -d
json
{"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:

bash
date -u -d @1790214491    # iat
date -u -d @1790215391    # exp
text
Thu Sep 24 01:48:11 UTC 2026
Thu Sep 24 02:03:11 UTC 2026

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

text
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út
bash
api-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ẮT

Nguyê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:

bash
sudo timedatectl set-ntp true
sudo systemctl restart systemd-timesyncd
timedatectl status | grep synchronized      # xác nhận: yes

Vấ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ặngCâu hỏiCông cụ
Trình duyệtRequest có được gửi đúng không?DevTools → Network
DNSTên miền phân giải ra IP nào?nslookup, dig
HTTPSChứng chỉ có hợp lệ không?Ổ khoá trên thanh địa chỉ, openssl s_client
HTTPServer trả về gì, kèm header nào?curl -i
BackendXử lý tới đâu thì lỗi?Log ứng dụng, breakpoint
DatabaseTruy 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):

text
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ặngTrong DevToolsThời gianTỉ lệ
1. Trình duyệt chuẩn bịQueueing + Stalled1,6 ms0,6%
2. DNS + TCP + TLSDNS + Initial connection72,0 ms26,5%
3. Gửi requestRequest sent0,2 ms0,1%
4. Máy chủ xử lýWaiting (TTFB)186,0 ms68,4%
5. Nhận phản hồiContent Download12,0 ms4,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.

text
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ào

2. 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:

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

text
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ọng

Bốn chặng đầu đo được mà không cần trình duyệt:

bash
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=1
text
dns:      0.024301s
tcp:      0.072118s
tls:      0.103447s
ttfb:     0.289512s
tong:     0.301776s
text
Lư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 ms

Và một chặng mà DevTools không hiện ra, nhưng thường là nguyên nhân thật:

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

text
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 đâu

Bà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:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4ODQyIiwibmFtZSI6Ik5ndXllbiBWYW4gQW4iLCJ0ZW5hbnQiOiJtZWdhY29ycCIsImlhdCI6MTc5MDAwMDAwMCwibmJmIjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDA5MDB9.xsch3d9zSYqoCf_cI5kCcATAJkq7XA-7RQ3WHQuq4Zk

Dù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:

bash
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4ODQyIiwibmFtZSI6Ik5ndXllbiBWYW4gQW4iLCJ0ZW5hbnQiOiJtZWdhY29ycCIsImlhdCI6MTc5MDAwMDAwMCwibmJmIjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDA5MDB9.xsch3d9zSYqoCf_cI5kCcATAJkq7XA-7RQ3WHQuq4Zk'
jwt_payload "$TOKEN"
json
{"sub":"8842","name":"Nguyen Van An","tenant":"megacorp","iat":1790000000,"nbf":1790000000,"exp":1790000900}
bash
date -u -d @1790000000 +%FT%TZ    # iat và nbf
date -u -d @1790000900 +%FT%TZ    # exp
text
2026-09-21T14:13:20Z
2026-09-21T14:28:20Z

Thời hạn sống: 900 giây = 15 phút. Phần header giải mã cùng cách:

json
{"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Ý.

text
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 gian

2. Vì vậy, không đặt dữ liệu nhạy cảm vào token.

Đặt đượcKhông đặt
Id người dùngSố căn cước, số thẻ
Tên hiển thịSố điện thoại, địa chỉ
Mã tenantLươ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
text
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.

text
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ườngNghĩaMáy chủ kiểm tra
iatLúc phát hànhThường chỉ để ghi nhận, và để thu hồi theo mốc thời gian
nbfKhông hợp lệ trước thời điểm nàyToken chưa tới giờ dùng → từ chối
expHết hạn sau thời điểm nàyToken quá hạn → từ chối
text
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ờ:

text
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.
text
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 ngay

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

text
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ực

Bà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:

bash
dotnet new web -n ClockSkewLab
cd ClockSkewLab
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Thay 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ỹ):

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

bash
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 -u

Kết quả chạy thật trên .NET 10:

text
== 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 2026

Và log của API, ở lần lệch 3 phút:

text
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ểmKết quảThông điệp
Đúng giờ200
Nhanh 3 phút200, kèm cảnh báo trong logLệch nằm trong ClockSkew 5 phút
Chậm 25 phút401The token expired at ...
Nhanh 25 phút401The token is not valid before ...

Hai thông điệp lỗi này là manh mối quan trọng nhất:

text
"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át
text
Và 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:

text
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.
text
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ìnhKhi nào
ClockSkew = TimeSpan.ZeroKhi 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útGần như luôn là dấu hiệu che giấu một vấn đề về đồng hồ
text
Đặ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ện

Cách phòng đúng: sửa đồng hồ, không nới ClockSkew. Trên Linux:

bash
sudo timedatectl set-ntp true
timedatectl status
text
System clock synchronized: yes
NTP service: active

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

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

text
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 được

Và đâ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:

  1. Kiểm tra từ ngoài vào trong. DevTools trước, code sau.
  2. Đọ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.
  3. 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 ​

Điều hướng ​

Phát hành theo giấy phép MIT.