Một team ra mắt API mới xài JWT vì "session không scale được". Sáu tháng sau có khách báo bị mất laptop. Support bấm "đăng xuất mọi thiết bị", và không có gì xảy ra: access token sống tới 30 ngày mà server không có cách nào vô hiệu hóa nó. Cách sửa được gắn thêm vô là một bảng revoked_tokens kiểm tra ở mọi request, tức là một session store nhưng nhiều bước hơn và cookie to hơn.
JWT là công cụ tốt cho một việc cụ thể. Nó cũng là công nghệ auth bị xài lố nhiều nhất trong web. Trước khi chọn, nên nói cho rõ JWT cho mình cái gì, lấy đi cái gì, và mấy lựa chọn nhàm chán (session phía server và opaque token, Laravel Sanctum cho sẵn hết) vốn đã giải quyết được gì rồi.
JWT thật ra là gì
JSON Web Token gồm ba đoạn base64url: header, payload chứa các claim, và chữ ký trên hai đoạn đầu.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MzAyMjUwMH0.<signature>
header = {"alg":"HS256","typ":"JWT"}
payload = {"sub":"42","exp":1763022500} <- base64url, NOT encrypted
signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))
Chữ ký chứng minh token được phát hành bởi người giữ key và không bị sửa. Chỉ vậy thôi. Payload thì ai cầm token cũng đọc được, nên đừng bao giờ bỏ bí mật hay dữ liệu cá nhân vô đó. Mấy claim chuẩn nên luôn set và luôn kiểm tra: exp (hết hạn), iss (bên phát hành), aud (bên nhận), sub (chủ thể), và thường có thêm iat với jti (id duy nhất).
Điểm làm JWT hấp dẫn là verify kiểu stateless: service nào có key (HS256) hoặc public key (RS256, ES256, EdDSA) cũng kiểm tra được token mà không cần đụng database hay gọi mạng. Cái này có giá trị khi nhiều service độc lập cùng phải xác thực một caller, hoặc khi một identity provider phát token cho API mà nó không chạy (OAuth 2.0 / OpenID Connect).
Session phía server: mặc định nhàm chán
Với session, browser giữ một id ngẫu nhiên, không mang ý nghĩa gì, trong cookie. Server tra nó trong Redis hoặc database mỗi request. Mình được:
- Thu hồi tức thì: xóa dòng session là user bị đá ra, trên mọi thiết bị nếu muốn.
- Cookie nhỏ: 40 byte thay vì token 1 KB đi theo mọi request.
- State phía server đổi được mà không phải phát lại gì: role, impersonate, trạng thái 2FA.
Cái giá là một lần tra key-value mỗi request, Redis làm chưa tới một mili giây. "Session không scale" đúng với session lưu trong RAM kiểu sticky trên một server hồi 2008; với Redis dùng chung thì nó không phải ràng buộc thật cho đại đa số app.
Bài toán thu hồi
JWT còn hiệu lực cho tới exp, bất kể chuyện gì xảy ra phía server. Đổi mật khẩu? Tài khoản bị khóa? Token lọt vô file log? Nó vẫn chạy. Chỉ có ba lối ra, và lối nào cũng phải hy sinh một phần stateless:
- Thời gian sống ngắn: access token 5 tới 15 phút, token có lộ thì cũng chết nhanh.
- Denylist các giá trị
jti(hoặc một mốc "token phát trước X là vô hiệu" cho từng user) kiểm tra mỗi request. Vậy là lại có một lần tra cứu. - Refresh token lưu phía server và thu hồi được, để user không lấy được access token mới khi mình đã nói không.
Pattern trên production kết hợp 1 và 3: access token sống ngắn cộng với refresh token sống dài, xoay vòng và thu hồi được.
Access token + xoay vòng refresh token
Mỗi lần client xài refresh token, server đánh dấu nó đã dùng và phát cái mới trong cùng một family. Nếu một refresh token đã dùng rồi mà còn xuất hiện lại, thì hoặc client có bug, hoặc kẻ tấn công đã copy nó. Mình không biết bản nào là hợp lệ, nên thu hồi nguyên family và bắt login lại.
Đây là bản cài đặt bằng Laravel. Bảng refresh_tokens có user_id, family_id, token_hash, used_at, revoked_at và expires_at (cast sang datetime trong model).
namespace App\Auth;
use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class RefreshTokenService
{
public function issue(User $user, ?string $familyId = null): string
{
$plain = Str::random(64);
RefreshToken::query()->create([
'user_id' => $user->id,
'family_id' => $familyId ?? (string) Str::uuid(),
'token_hash' => hash('sha256', $plain), // store only a hash, like Sanctum does
'expires_at' => now()->addDays(14),
]);
return $plain;
}
/** @return array{0: User, 1: string} */
public function rotate(string $plain): array
{
$result = DB::transaction(function () use ($plain): array|string {
$token = RefreshToken::query()
->where('token_hash', hash('sha256', $plain))
->lockForUpdate()
->first();
if ($token === null) {
return 'invalid';
}
if ($token->used_at !== null) {
// Someone presented an already-rotated token: assume theft.
RefreshToken::query()
->where('family_id', $token->family_id)
->update(['revoked_at' => now()]);
return 'reuse';
}
if ($token->revoked_at !== null || $token->expires_at->isPast()) {
return 'invalid';
}
$token->update(['used_at' => now()]);
return [$token->user, $this->issue($token->user, $token->family_id)];
});
// Throw AFTER commit, otherwise the family revocation is rolled back.
if (is_string($result)) {
throw new AuthenticationException("Refresh token {$result}.");
}
return $result;
}
}
Để ý cái bug tinh vi mà code này né được: throw exception bên trong DB::transaction() sẽ rollback transaction, tức là hủy luôn việc thu hồi family đúng lúc mình cần nó nhất. Trả về trạng thái rồi throw sau khi commit thì việc thu hồi được giữ lại. lockForUpdate() chặn hai lần gọi refresh song song (double click, hai tab browser) cùng xoay một token.
Endpoint refresh phát một Sanctum token 15 phút làm access token, và để refresh token trong cookie mà JavaScript không đọc được:
// routes/api.php
use App\Auth\RefreshTokenService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/auth/refresh', function (Request $request, RefreshTokenService $refresh) {
[$user, $newRefresh] = $refresh->rotate((string) $request->cookie('refresh_token'));
$access = $user->createToken('access', ['*'], now()->addMinutes(15))->plainTextToken;
return response()
->json(['access_token' => $access, 'expires_in' => 900])
// name, value, minutes, path, domain, secure, httpOnly, raw, sameSite
->cookie('refresh_token', $newRefresh, 60 * 24 * 14, '/api/auth', null, true, true, false, 'strict');
})->middleware('throttle:20,1');
Lưu token ở đâu trên browser
localStorage/sessionStorage: JavaScript nào trên trang cũng đọc được. Một bug XSS, một npm dependency hay script analytics bị chiếm, là mọi token bị lấy ra và xài được từ máy kẻ tấn công.- Cookie
HttpOnly; Secure; SameSite: JavaScript không đọc được, nên XSS vẫn xài ké session lúc trang đang mở nhưng không đánh cắp được nó. Cookie thì kéo CSRF quay lại, vàSameSite=LaxhoặcStrictcộng CSRF token của Laravel lo phần đó. - Trong bộ nhớ (một biến JS): chỗ hợp lý cho access token sống ngắn trong SPA. Reload là mất, refresh cookie sẽ lấy cái mới.
Giới hạn refresh cookie bằng Path=/api/auth để nó chỉ được gửi tới endpoint refresh, không đi theo mọi lần gọi API.
Nếu ký JWT trong Node hay ở edge thì xài thư viện có người bảo trì và cố định thuật toán:
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function issueAccessToken(userId: string): Promise<string> {
return new SignJWT({ scope: 'orders:read' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer('https://api.example.com')
.setAudience('orders-service')
.setIssuedAt()
.setExpirationTime('15m')
.setJti(crypto.randomUUID())
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'], // never let the token choose its own algorithm
issuer: 'https://api.example.com',
audience: 'orders-service',
clockTolerance: 30,
});
return payload;
}
Laravel Sanctum: thường là thứ mình cần
Sanctum lo được hai trường hợp phổ biến mà không cần JWT gì hết. Cài bằng php artisan install:api.
Với SPA first-party cùng domain cấp cao nhất, Sanctum xài session cookie bình thường. Không có token nào trong JavaScript, thu hồi là "xóa session", còn CSRF do flow /sanctum/csrf-cookie lo.
// bootstrap/app.php: first-party SPA on the same site uses plain session cookies
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(api: __DIR__.'/../routes/api.php')
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi(); // SANCTUM_STATEFUL_DOMAINS=app.example.com
})
->create();
Với app mobile và client bên thứ ba, Sanctum phát personal access token dạng opaque. Đó là chuỗi ngẫu nhiên lưu dạng hash SHA-256 trong personal_access_tokens, kiểm tra bằng một query có index, muốn thu hồi thì xóa. Mỗi token gán được ability và hạn dùng, dọn row cũ bằng php artisan sanctum:prune-expired.
// Mobile app or third-party client: opaque, revocable Sanctum tokens
$token = $user->createToken('ios-app', ['orders:read'], now()->addMinutes(15));
return ['access_token' => $token->plainTextToken, 'expires_in' => 900];
// Logout this device / everywhere
$request->user()->currentAccessToken()->delete();
$request->user()->tokens()->delete();
Kiểm thử xoay vòng refresh token (test)
Hành vi quan trọng nhất lại là cái không ai bấm thử bằng tay: replay một refresh token cũ phải giết nguyên family. Với RefreshDatabase bật trong tests/Pest.php:
use App\Auth\RefreshTokenService;
use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
it('rotates once and burns the whole family on reuse', function () {
$service = app(RefreshTokenService::class);
$first = $service->issue(User::factory()->create());
[, $second] = $service->rotate($first);
expect($second)->not->toBe($first);
expect(fn () => $service->rotate($first))
->toThrow(AuthenticationException::class, 'Refresh token reuse.');
// The legitimate-looking newer token dies with its family.
expect(fn () => $service->rotate($second))->toThrow(AuthenticationException::class);
expect(RefreshToken::query()->whereNull('revoked_at')->count())->toBe(0);
});
it('rejects expired refresh tokens', function () {
$service = app(RefreshTokenService::class);
$token = $service->issue(User::factory()->create());
$this->travel(15)->days();
expect(fn () => $service->rotate($token))->toThrow(AuthenticationException::class);
});
Thêm một feature test kiểm tra refresh cookie có HttpOnly và Secure (assertCookie() cộng soi header Set-Cookie), vì một lần refactor làm rớt mấy cờ đó sẽ không làm hỏng test chức năng nào.
Mấy lỗi hay gặp
- Tin header
algcủa token. Mấy thư viện từng chấp nhậnalg: none, hoặc nhận public key RS256 làm secret HS256, đã gây ra breach thật. Luôn truyền danh sách thuật toán cho phép một cách rõ ràng. - Access token sống dài. JWT 30 ngày là mật khẩu 30 ngày không ai đổi được. Giữ access token ở mức phút.
- Không kiểm tra
audvàiss. Không thì token phát cho service A sẽ được service B dùng chung key chấp nhận luôn. - Dữ liệu cá nhân trong payload. Base64url là mã hóa ký tự, không phải mã hóa bảo mật. Email, role, id nội bộ ai cầm token cũng thấy.
- Refresh token không xoay vòng, không lưu. Refresh token stateless chỉ là access token sống dài thêm vài bước.
- Secret HS256 yếu. Key HMAC có thể bị brute-force offline chỉ từ một token. Xài ít nhất 256 bit ngẫu nhiên, đừng bao giờ là chữ kiểu
secret. - Xài JWT làm session cho một monolith. Trả giá thu hồi với kích thước mà chẳng được gì.
Khi nào xài JWT (và khi nào không)
- Xài JWT cho OAuth 2.0 / OpenID Connect với identity provider bên ngoài, cho auth giữa nhiều service không nên chung database, và cho token sống ngắn, một mục đích (link download có ký, xác thực email) khi stateless thật sự có ích.
- Xài session phía server (Sanctum chế độ SPA) cho frontend web first-party nói chuyện với backend Laravel của chính nó. Đơn giản hơn và an toàn hơn.
- Xài opaque token (personal access token của Sanctum) cho app mobile và API key, khi cần thu hồi theo từng thiết bị chỉ với một lần tra có index.
- Kết hợp access token JWT sống ngắn với refresh token có lưu và xoay vòng, chỉ khi thật sự cần verify stateless ở downstream mà vẫn phải đăng xuất được người dùng.
Câu cần hỏi không phải "JWT hay session?" mà là "ai cần verify credential này, và mình phải thu hồi nó nhanh cỡ nào?" Nếu câu trả lời là "chỉ app của mình" và "ngay lập tức", thì mình cần session.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết RefreshTokenStore (PHP và Python) có xoay vòng refresh token và phát hiện reuse. Constructor nhận clock (trả về Unix time hiện tại dạng int), id generator (mỗi lần gọi trả về một chuỗi mới không trùng) và ttlSeconds / ttl_seconds (mặc định 1209600). issue(userId) tạo một family mới (lấy một id sinh ra làm family id) và trả về token mới trong family đó; mỗi token hết hạn ở clock() + ttl. rotate(token): token không tồn tại thì throw InvalidTokenException (Python: InvalidTokenError); token đã dùng rồi thì thu hồi nguyên family của nó và throw TokenReuseException (Python: TokenReuseError); family đã bị thu hồi hoặc clock() lớn hơn hoặc bằng hạn của token thì throw InvalidTokenException; còn lại thì đánh dấu đã dùng và trả về token mới cùng family với hạn mới. revokeUser(userId) / revoke_user(user_id) thu hồi mọi family của user đó. Chỉ xài clock và id generator được truyền vô.
Code khởi tạo
final class InvalidTokenException extends RuntimeException {}
final class TokenReuseException extends RuntimeException {}
final class RefreshTokenStore
{
public function __construct(
private Closure $clock,
private Closure $idGenerator,
private int $ttlSeconds = 1209600,
) {}
public function issue(string $userId): string
{
// TODO: start a new family and return a new token in it
throw new LogicException('TODO');
}
public function rotate(string $token): string
{
// TODO: unknown -> InvalidTokenException
// TODO: already used -> revoke its family, TokenReuseException
// TODO: family revoked or clock() >= expiry -> InvalidTokenException
// TODO: mark used, return a new token in the same family
throw new LogicException('TODO');
}
public function revokeUser(string $userId): void
{
// TODO: revoke every family that belongs to $userId
throw new LogicException('TODO');
}
}
from typing import Callable
class InvalidTokenError(Exception):
pass
class TokenReuseError(Exception):
pass
class RefreshTokenStore:
def __init__(self, clock: Callable[[], int], id_generator: Callable[[], str], ttl_seconds: int = 1209600) -> None:
self.clock = clock
self.id_generator = id_generator
self.ttl_seconds = ttl_seconds
def issue(self, user_id: str) -> str:
# TODO: start a new family and return a new token in it
raise NotImplementedError
def rotate(self, token: str) -> str:
# TODO: unknown -> InvalidTokenError
# TODO: already used -> revoke its family, TokenReuseError
# TODO: family revoked or clock() >= expiry -> InvalidTokenError
# TODO: mark used, return a new token in the same family
raise NotImplementedError
def revoke_user(self, user_id: str) -> None:
# TODO: revoke every family that belongs to user_id
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýMỗi token lưu: user, family, hạn dùng và cờ đã dùng; family bị thu hồi để trong một set riêng
Kiểm tra 'đã dùng' trước 'bị thu hồi hoặc hết hạn' để token bị replay luôn kích hoạt thu hồi family
Xoay vòng không tạo family mới: token mới kế thừa family id của token cũ
final class InvalidTokenException extends RuntimeException {}
final class TokenReuseException extends RuntimeException {}
final class RefreshTokenStore
{
/** @var array<string, array{user: string, family: string, expires: int, used: bool}> */
private array $tokens = [];
/** @var array<string, true> */
private array $revokedFamilies = [];
public function __construct(
private Closure $clock,
private Closure $idGenerator,
private int $ttlSeconds = 1209600,
) {}
public function issue(string $userId): string
{
return $this->mint($userId, ($this->idGenerator)());
}
public function rotate(string $token): string
{
$record = $this->tokens[$token] ?? throw new InvalidTokenException('Unknown refresh token.');
if ($record['used']) {
$this->revokedFamilies[$record['family']] = true;
throw new TokenReuseException('Refresh token reused, family revoked.');
}
if (isset($this->revokedFamilies[$record['family']]) || ($this->clock)() >= $record['expires']) {
throw new InvalidTokenException('Refresh token revoked or expired.');
}
$this->tokens[$token]['used'] = true;
return $this->mint($record['user'], $record['family']);
}
public function revokeUser(string $userId): void
{
foreach ($this->tokens as $record) {
if ($record['user'] === $userId) {
$this->revokedFamilies[$record['family']] = true;
}
}
}
private function mint(string $userId, string $family): string
{
$token = ($this->idGenerator)();
$this->tokens[$token] = [
'user' => $userId,
'family' => $family,
'expires' => ($this->clock)() + $this->ttlSeconds,
'used' => false,
];
return $token;
}
}
from typing import Callable
class InvalidTokenError(Exception):
pass
class TokenReuseError(Exception):
pass
class RefreshTokenStore:
def __init__(self, clock: Callable[[], int], id_generator: Callable[[], str], ttl_seconds: int = 1209600) -> None:
self.clock = clock
self.id_generator = id_generator
self.ttl_seconds = ttl_seconds
self.tokens: dict[str, dict] = {}
self.revoked_families: set[str] = set()
def issue(self, user_id: str) -> str:
return self._mint(user_id, self.id_generator())
def rotate(self, token: str) -> str:
record = self.tokens.get(token)
if record is None:
raise InvalidTokenError("unknown refresh token")
if record["used"]:
self.revoked_families.add(record["family"])
raise TokenReuseError("refresh token reused, family revoked")
if record["family"] in self.revoked_families or self.clock() >= record["expires"]:
raise InvalidTokenError("refresh token revoked or expired")
record["used"] = True
return self._mint(record["user"], record["family"])
def revoke_user(self, user_id: str) -> None:
for record in self.tokens.values():
if record["user"] == user_id:
self.revoked_families.add(record["family"])
def _mint(self, user_id: str, family: str) -> str:
token = self.id_generator()
self.tokens[token] = {
"user": user_id,
"family": family,
"expires": self.clock() + self.ttl_seconds,
"used": False,
}
return token

