---
title: "Đổi khoá ký đăng nhập mà không làm ai bị đăng xuất"
url: https://phatdev.net/vi/blog/jwt-rs256-key-rotation
lang: vi
updated: 2026-10-21
alternate:
  en: https://phatdev.net/en/blog/jwt-rs256-key-rotation
markdown: https://phatdev.net/vi/blog/jwt-rs256-key-rotation.md
---

# Đổi khoá ký đăng nhập mà không làm ai bị đăng xuất

> Cách đổi khoá ký JWT theo từng bước để người dùng đang đăng nhập không bị văng ra: khoá cặp RS256/ES256, bảng khoá JWKS và thứ tự xuất bản, ký, gỡ khoá cũ.

Chuyên mục: Bảo mật | Đăng: 2026-10-19 | Cấp độ: Nâng cao | 6 phút đọc

Tags: jwt, rs256, es256, jwks, key-rotation, nodejs, dotnet

Có lúc bạn nghi khoá ký đăng nhập đã bị lộ, hoặc đơn giản là đến kỳ phải đổi. Cách nhanh nhất là thay khoá mới rồi khởi động lại máy chủ. Sáng hôm sau, toàn bộ người dùng đang đăng nhập bị văng ra, vì mọi thẻ cũ đều không còn khớp với khoá. Với một shop đang chạy khuyến mãi thì đó là một buổi sáng rất dài. Đổi khoá được mà không ai hay biết, miễn là làm đúng thứ tự.

![Dòng thời gian xoay khoá: khoá cũ mờ dần, khoá mới xuất hiện, giữa là vùng cả hai cùng hoạt động](https://phatdev.net/uploads/blog/jwt-rs256-key-rotation/rotate-1920.webp)

## Vì sao nên dùng khoá cặp

Bài trước ([Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#](https://phatdev.net/vi/blog/jwt-across-languages)) dùng một bí mật chung: bên cấp thẻ và bên kiểm thẻ cầm cùng một khoá. Nhỏ thì ổn. Khi có nhiều dịch vụ, ví dụ cổng API, dịch vụ báo cáo, dịch vụ thanh toán đều cần kiểm thẻ, thì cả ba đều giữ bí mật đó. Một nơi bị lộ là ai cũng tự cấp được thẻ giả.

Cách gọn hơn là dùng một cặp khoá. Hãy nghĩ tới con dấu của công ty. Khoá riêng là con dấu thật, chỉ nằm ở nơi cấp thẻ (gọi là server A). Khoá công khai là bản mẫu dấu dán ở quầy, ai cũng lấy được để đối chiếu, nhưng không đóng dấu được. Server B chỉ giữ khoá công khai nên kiểm được thẻ mà không bao giờ làm giả được. Hai kiểu hay dùng là RS256 (khoá RSA) và ES256 (đường cong elliptic). RS256 chạy ở mọi nơi, còn ES256 cho thẻ ngắn hơn. Với cùng nội dung, mình đo thẻ RS256 dài 570 ký tự, ES256 chỉ 313.

![Server A giữ khoá riêng để ký, server B chỉ giữ khoá công khai để kiểm](https://phatdev.net/uploads/blog/jwt-rs256-key-rotation/keys-1920.webp)

## Bảng niêm yết khoá và số hiệu khoá

Gửi tay file khoá công khai cho từng dịch vụ thì sẽ có lúc quên một nơi. Cách chuẩn là server A dựng một địa chỉ công khai (thường là `/.well-known/jwks.json`) liệt kê các khoá công khai đang dùng. Danh sách đó gọi là JWKS. Mỗi khoá có một số hiệu (gọi là `kid`), và thẻ nào cũng ghi số hiệu khoá đã ký nó. Server B nhìn số hiệu trên thẻ, tra bảng, lấy đúng khoá. Thư viện lo giúp việc tải bảng, giữ bản sao tạm và tải lại khi gặp số hiệu lạ.

Bảng có thể chứa nhiều khoá cùng lúc. Đó là mấu chốt của việc đổi khoá êm ái.

## Làm thế nào: đổi khoá bốn bước

1. Tạo khoá mới (gọi là k2), thêm vào bảng niêm yết nhưng chưa dùng để ký. Chờ cho các server B kịp tải bảng mới, tối thiểu bằng thời gian chúng giữ bản sao tạm.
2. Cho server A chuyển sang ký bằng k2. Thẻ cũ ký bằng k1 vẫn kiểm được, vì k1 còn trong bảng.
3. Chờ thẻ cuối cùng ký bằng k1 hết hạn. Thời gian chờ là hạn thẻ dài nhất (ví dụ 15 phút) cộng thời gian giữ bản sao tạm.
4. Gỡ k1 khỏi bảng và huỷ khoá riêng của k1.

Mình dựng một server A phát bảng khoá qua HTTP và một server B chỉ biết địa chỉ bảng, rồi chạy đúng quy trình trên. Kết quả thật:

```
1) Chỉ có k1                         OK token k1
2) Xuất bản k2, vẫn ký bằng k1       OK token k1
3) Chuyển sang ký bằng k2            OK token k1 (cũ), OK token k2 (mới)
4) Gỡ k1 khỏi bảng                   OK token k2, REJECT token k1
5) Sai thứ tự: ký bằng k3 khi chưa xuất bản
                                     REJECT token k3
   Sau khi xuất bản và hết thời gian chờ   OK token k3
```

Dòng số 5 là lỗi mình gặp nhiều nhất: đổi sang khoá mới trước khi bảng kịp lan ra. Thẻ mới bị từ chối cho đến khi B tải lại bảng. Quy tắc nhớ gọn là xuất bản trước, ký sau.

## Cho bạn nào viết code

Để thử, sinh khoá bằng `openssl`. Khi chạy thật, khoá riêng nên nằm trong dịch vụ quản lý khoá của cloud hoặc kho bí mật, không nằm trong repo.

```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-private.pem
openssl pkey -in rsa-private.pem -pubout -out rsa-public.pem
chmod 600 rsa-private.pem
```

Server A ký và ghi số hiệu khoá vào header của thẻ (Node.js với `jose`):

```js
import { readFileSync } from 'node:fs';
import { importPKCS8, SignJWT } from 'jose';
const priv = await importPKCS8(readFileSync('rsa-private.pem', 'utf8'), 'RS256');
const token = await new SignJWT({ role: 'editor' })
  .setProtectedHeader({ alg: 'RS256', kid: 'k2' })
  .setSubject('user-42').setIssuer('https://auth.example.test')
  .setAudience('https://api.example.test').setIssuedAt().setExpirationTime('15m')
  .sign(priv);
```

Server B chỉ biết địa chỉ bảng. `createRemoteJWKSet` giữ bản sao tạm 10 phút, và khi gặp số hiệu lạ thì tải lại, nhưng cách nhau ít nhất 30 giây để người gửi thẻ linh tinh không làm B bị dội yêu cầu:

```js
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://auth.example.test/.well-known/jwks.json'));
await jwtVerify(token, jwks, { algorithms: ['RS256'], issuer: ISS, audience: AUD });
```

Python và PHP kiểm cùng một thẻ, cùng địa chỉ bảng, chọn đúng khoá theo số hiệu: `PyJWKClient(url).get_signing_key_from_jwt(token)` rồi `jwt.decode(...)`, và `JWK::parseKeySet(json, 'RS256')` rồi `JWT::decode(...)`. Mình chạy chéo cả RS256 lẫn ES256, ký bằng Node, kiểm bằng Node, Python, PHP chỉ với khoá công khai: 3 trên 3 chấp nhận cho cả hai.

Với .NET, mình chưa chạy (máy không có runtime). Gói `Microsoft.IdentityModel.JsonWebTokens` dùng `RsaSecurityKey` có `KeyId` để ký, còn phía kiểm tra khoá theo `kid` từ `JsonWebKeySet`. Nếu dùng ASP.NET Core `AddJwtBearer` với `Authority`, middleware tự tải và làm mới bảng khoá, bạn chỉ cần chỉnh chu kỳ làm mới cho khớp lịch đổi khoá.

## Lỗi hay gặp

Có một cú lừa kinh điển mà mình đã thử lại: kẻ xấu lấy khoá công khai (ai cũng có) làm bí mật HS256 để ký thẻ giả, hy vọng B dùng cùng một khoá cho cả hai kiểu. Node, PyJWT và firebase/php-jwt đều từ chối, miễn là bạn khai danh sách thuật toán cố định ở phía kiểm. Nếu thấy thư viện nào để thuật toán thẻ tự quyết, hãy đổi cách gọi.

Mấy lỗi còn lại thì gần với đời thường. Hai khoá khác nhau mà trùng số hiệu thì B lấy nhầm, nên đặt số hiệu có ngày tạo. Gỡ khoá cũ sớm quá thì người dùng còn thẻ sống bị văng. Bảng niêm yết lỡ chứa cả khoá riêng thì mất hết, nên chỉ phát phần công khai. Và đừng bao giờ lấy địa chỉ khoá từ chính thẻ (trường `jku`, `jwk`): địa chỉ bảng phải được cấu hình cứng ở phía kiểm.

## Nên làm gì tiếp

Nếu bạn đang dùng khoá chung HS256 ở nhiều dịch vụ, hãy lên lịch chuyển sang khoá cặp, rồi tập đổi khoá một lần trên môi trường thử để có sẵn quy trình trước khi cần gấp. Ghi lại hạn thẻ dài nhất và thời gian giữ bản sao tạm, hai con số đó quyết định bạn phải chờ bao lâu ở bước 1 và bước 3. Để giấu dữ liệu nằm trong database, xem [Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM](https://phatdev.net/vi/blog/aes-gcm-done-right).

## Nguồn tham khảo

- [RFC 7517 - JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517)
- [RFC 7518 - JSON Web Algorithms (RS256, ES256)](https://www.rfc-editor.org/rfc/rfc7518)
- [RFC 8725 - JWT Best Current Practices](https://www.rfc-editor.org/rfc/rfc8725)
- [jose - createRemoteJWKSet](https://github.com/panva/jose/blob/main/docs/jwks/remote/functions/createRemoteJWKSet.md)
- [PyJWT - PyJWKClient](https://pyjwt.readthedocs.io/en/stable/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint)
- [firebase/php-jwt - JWK](https://github.com/firebase/php-jwt)
- [Microsoft Learn - JsonWebTokenHandler](https://learn.microsoft.com/en-us/dotnet/api/microsoft.identitymodel.jsonwebtokens.jsonwebtokenhandler)

## Bài liên quan

- [Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#](https://phatdev.net/vi/blog/jwt-across-languages.md)
- [Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM](https://phatdev.net/vi/blog/aes-gcm-done-right.md)
- [Passkey là gì: đăng nhập không cần mật khẩu, và có nên thêm vào web của bạn không](https://phatdev.net/vi/blog/passkeys-explained.md)
