Code xác thực nằm ngay trên ranh giới giữa kẻ tấn công và mọi năng lực mà ứng dụng cung cấp. Một thiết kế có thể dùng mật mã mạnh nhưng vẫn thất bại vì nhầm danh tính với quyền hạn, tin token chưa được kiểm tra, lưu bearer credential ở nơi script đọc được hoặc nhận OAuth redirect mà không ràng buộc response với browser đã khởi tạo giao dịch.
Không có một credential tốt nhất cho mọi hệ thống. Ứng dụng web first-party thường phù hợp với opaque session ở server. API phân tán có thể cần access token sống ngắn. Quyền truy cập ủy quyền vào sản phẩm khác cần OAuth 2.0, còn đăng nhập qua identity provider cần lớp danh tính do OpenID Connect cung cấp. Bài toán kỹ thuật là chọn protocol nhỏ nhất đáp ứng nhu cầu, kiểm tra mọi boundary và luôn giữ cách kết thúc quyền hạn đã bị lộ.
Authentication xác lập danh tính; authorization giới hạn hành động
Authentication trả lời “principal nào đang gửi request?” Principal có thể là người dùng, service, device hoặc workload. Password, passkey, session cookie, client certificate và token đã xác minh là các cách thiết lập hoặc mang theo danh tính đó.
Authorization trả lời “principal này có được thực hiện hành động này trên resource này vào lúc này không?” Quyết định phải xét role hoặc scope, quyền sở hữu resource, tenant boundary, trạng thái account và policy theo ngữ cảnh. Authentication nên tạo ra identity nhỏ, đáng tin như { subject, tenantId, assurance }; operation nghiệp vụ sau đó authorize bằng dữ liệu hiện tại.
type Principal = {
subject: string;
tenantId: string;
scopes: ReadonlySet<string>;
};
async function deleteInvoice(principal: Principal, invoiceId: string) {
const invoice = await invoices.findById(invoiceId);
if (!invoice || invoice.tenantId !== principal.tenantId) {
throw new HttpError(404, 'invoice not found');
}
if (!principal.scopes.has('invoice:delete')) {
throw new HttpError(403, 'forbidden');
}
await invoices.delete(invoiceId);
}
Chỉ kiểm tra role === 'admin' ở route thường chưa đủ. Object-level authorization ngăn insecure direct object reference, còn tenant phải lấy từ principal đã xác thực chứ không phải header do caller điều khiển. Trả 401 Unauthorized khi thiếu authentication hợp lệ và 403 Forbidden khi principal đã xác thực nhưng không đủ quyền. Một số hệ thống chủ động trả 404 để không tiết lộ resource tồn tại.
Step-up authentication hữu ích cho hành động nhạy cảm. Session đã ghi nhớ có thể đủ để đọc profile nhưng không đủ để xoay recovery code hay duyệt chuyển tiền. Hãy ghi thời điểm và assurance của lần xác thực, rồi yêu cầu passkey, password hoặc yếu tố thứ hai gần đây thay vì coi possession của session cũ là bằng chứng vĩnh viễn.
Server session và browser cookie
Session cookie nên chỉ chứa identifier opaque không thể đoán, không chứa dữ liệu user. Server lưu session record nên có thể thu hồi ngay. Tạo ít nhất 128 bit bằng nguồn random an toàn mật mã, chỉ lưu hash của identifier và xoay identifier sau login hoặc thay đổi đặc quyền để ngăn session fixation.
import { createHash, randomBytes } from 'node:crypto';
type Session = {
userId: string;
expiresAt: Date;
};
const digest = (value: string) =>
createHash('sha256').update(value, 'utf8').digest('hex');
async function createSession(userId: string): Promise<string> {
const id = randomBytes(32).toString('base64url');
await sessionStore.insert({
idHash: digest(id),
userId,
expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000),
});
return id;
}
async function authenticateSession(id: string): Promise<Session | null> {
const session = await sessionStore.findByHash(digest(id));
if (!session || session.expiresAt.getTime() <= Date.now()) return null;
return session;
}
function sessionCookie(id: string, maxAgeSeconds: number): string {
return [
`__Host-session=${encodeURIComponent(id)}`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=Lax',
`Max-Age=${maxAgeSeconds}`,
].join('; ');
}
Prefix __Host- bắt buộc có Secure, Path=/ và không có Domain, nhờ đó subdomain không thể đặt cookie rộng hơn. HttpOnly ngăn JavaScript đọc value; nó không ngăn JavaScript bị inject gửi request đã xác thực. Secure bảo vệ đường truyền sau khi HTTPS được triển khai đúng. Dùng absolute lifetime, có thể thêm idle lifetime, rồi xóa cả record lẫn cookie khi logout. Trong cluster, dùng shared store nhất quán hoặc sticky routing có chủ đích; đừng vô tình tạo nhiều đảo session in-memory độc lập.
Cookie được browser tự gắn vào request, vì vậy request thay đổi trạng thái cần chống CSRF. SameSite=Lax là baseline tốt nhưng không phải policy hoàn chỉnh cho mọi browser flow. Kiểm tra Origin trên unsafe method và dùng synchronizer token lưu trong session, hoặc signed double-submit token được triển khai đúng. Không thay đổi trạng thái qua GET. CORS không chặn form submission cross-site thông thường.
Warning
Không đặt session identifier, authorization code hay bearer token trong URL. URL rò qua history, log, analytics, screenshot và referrer header. Hãy reject credential trong query parameter, trừ short-lived code mà protocol yêu cầu trên callback đã được kiểm tra.
| Thuộc tính | Server session | JWT access token tự chứa dữ liệu |
|---|---|---|
| Lookup mỗi request | Thường đọc cache hoặc database một lần | Xác minh signature và claim cục bộ |
| Thu hồi tức thời | Tự nhiên: xóa hoặc disable record | Cần expiry ngắn, introspection hoặc denylist |
| Độ mới payload | Trạng thái hiện tại ở server | Claim cũ đến khi token được thay |
| Scale ngang | Shared store hoặc routing strategy | Phân phối verification key đến service |
| Mặc định cho browser | Rất phù hợp với cookie HttpOnly |
Thường an toàn hơn sau backend-for-frontend |
| Failure mode chính | Store outage hoặc lỗi fixation/CSRF | Lỗ hổng validation, token bị lấy, lifetime dài, lỗi key |
Kiểm tra JWT, xoay vòng và thu hồi
JSON Web Token là serialization format, không phải authentication strategy. Signed JWT chỉ đáng tin sau khi receiver kiểm tra cryptographic signature và các claim mà protocol cục bộ yêu cầu. Base64-decode ba segment không chứng minh điều gì. Resource server phải chỉ định rõ algorithm, chỉ tin key từ issuer đã cấu hình, đồng thời kiểm tra issuer, audience, expiry và mục đích sử dụng token.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const issuer = 'https://identity.example.com/';
const audience = 'https://api.example.com';
const jwks = createRemoteJWKSet(
new URL('https://identity.example.com/.well-known/jwks.json'),
);
export async function verifyAccessToken(authorization?: string) {
const token = /^Bearer ([A-Za-z0-9._~-]+)$/.exec(authorization ?? '')?.[1];
if (!token) throw new HttpError(401, 'missing bearer token');
const { payload, protectedHeader } = await jwtVerify(token, jwks, {
issuer,
audience,
algorithms: ['RS256'],
clockTolerance: '5s',
requiredClaims: ['sub', 'iat', 'exp'],
});
if (typeof payload.sub !== 'string') {
throw new HttpError(401, 'invalid subject');
}
if (protectedHeader.typ && protectedHeader.typ !== 'at+jwt') {
throw new HttpError(401, 'wrong token type');
}
const scopes = typeof payload.scope === 'string'
? new Set(payload.scope.split(' ').filter(Boolean))
: new Set<string>();
return { subject: payload.sub, scopes };
}
Cache JWKS theo HTTP metadata và rate-limit refresh. Khi xoay signing key, publish key mới trước khi phát token dùng nó, giữ public key cũ đến khi toàn bộ token cũ hết hạn và chỉ dùng kid để chọn trong các key vốn đã được tin cậy cho issuer đó. Không fetch key URL tùy ý do header của untrusted token chỉ ra.
Giữ access token sống ngắn và scope hẹp. Refresh token là credential sống lâu hơn, chỉ gửi cho authorization server. Lưu refresh token như secret được bảo vệ; với public client, xoay nó sau mỗi lần dùng. Persist hash, token family, expiry, client, subject và thời điểm consumed. Nếu refresh token đã consumed xuất hiện lại, giả định có trộm cắp, revoke cả family và yêu cầu xác thực lại. Rotation mà không phát hiện reuse cũng cấp credential mới cho attacker.
Logout với JWT không có phép màu: xóa bản trong browser không vô hiệu bản sao khác. API rủi ro cao có thể dùng opaque token với introspection, denylist theo jti đến expiry hoặc lookup session/security version hiện tại. Mỗi cách thêm state, và đó thường là chi phí đúng để revoke nhanh. Không đặt secret hay dữ liệu cá nhân nhạy cảm trong JWT payload; signature cung cấp integrity chứ không confidentiality.
OAuth 2.0, OpenID Connect và PKCE
OAuth 2.0 ủy quyền truy cập. Resource owner cấp cho client quyền hạn giới hạn; authorization server phát token; resource server nhận access token cho API. Riêng OAuth không định nghĩa danh tính đăng nhập. OpenID Connect (OIDC) bổ sung ID token, identity claim chuẩn hóa, discovery, UserInfo và scope openid.
| Artifact | Audience và mục đích | Nơi nên gửi đến |
|---|---|---|
| Authorization code | Input dùng một lần cho token endpoint | Quay lại redirect URI đã kiểm tra của client |
| Access token | Resource server; authorize API call | Header Authorization: Bearer |
| Refresh token | Authorization server; lấy token mới | Chỉ credential storage được bảo vệ của client |
| ID token | OIDC client; mô tả authentication event | Logic validation của client, không dùng làm API bearer token |
Dùng authorization-code flow với PKCE cho browser, mobile, desktop và server client. PKCE ràng buộc authorization code bị lấy cắp với verifier do client khởi tạo giữ. state ràng buộc callback với browser transaction và chống login CSRF. nonce của OIDC ràng buộc ID token với transaction đó và hạn chế replay. Chúng giải quyết ba vấn đề khác nhau; không giá trị nào thay thế giá trị khác.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const randomValue = () => randomBytes(32).toString('base64url');
const sha256 = (value: string) =>
createHash('sha256').update(value, 'ascii').digest('base64url');
function equalSecret(left: string, right: string): boolean {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
function beginLogin(session: LoginSession): string {
const verifier = randomValue();
const state = randomValue();
const nonce = randomValue();
session.oauth = { verifier, state, nonce, createdAt: Date.now() };
const url = new URL('https://identity.example.com/authorize');
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'web-client',
redirect_uri: 'https://app.example.com/oauth/callback',
scope: 'openid profile email',
code_challenge: sha256(verifier),
code_challenge_method: 'S256',
state,
nonce,
}).toString();
return url.toString();
}
async function finishLogin(callback: URL, session: LoginSession) {
const pending = session.oauth;
delete session.oauth; // Mỗi transaction chỉ có một lần thử.
const returnedState = callback.searchParams.get('state') ?? '';
if (!pending || Date.now() - pending.createdAt > 10 * 60_000 ||
!equalSecret(returnedState, pending.state)) {
throw new HttpError(400, 'invalid OAuth transaction');
}
if (callback.searchParams.has('error')) {
throw new HttpError(400, 'authorization was not completed');
}
const code = callback.searchParams.get('code');
if (!code) throw new HttpError(400, 'missing authorization code');
const response = await fetch('https://identity.example.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'web-client',
redirect_uri: 'https://app.example.com/oauth/callback',
code,
code_verifier: pending.verifier,
}),
});
if (!response.ok) throw new HttpError(401, 'token exchange failed');
const tokens = await response.json() as { id_token?: string };
if (!tokens.id_token) throw new HttpError(401, 'missing ID token');
const { payload } = await jwtVerify(
tokens.id_token,
createRemoteJWKSet(new URL('https://identity.example.com/jwks')),
{
issuer: 'https://identity.example.com/',
audience: 'web-client',
algorithms: ['RS256'],
requiredClaims: ['sub', 'iat', 'exp', 'nonce'],
},
);
if (typeof payload.sub !== 'string' ||
typeof payload.nonce !== 'string' ||
!equalSecret(payload.nonce, pending.nonce)) {
throw new HttpError(401, 'invalid ID token claims');
}
return payload.sub;
}
Confidential client phải authenticate với token endpoint bằng method mà provider cấu hình, ưu tiên private_key_jwt hoặc mTLS khi được hỗ trợ; tuyệt đối không đưa client secret vào browser code. Đăng ký redirect URI chính xác và reject open redirect parameter. Trong production, dùng OIDC client library được duy trì để discovery, iss, aud, azp, signature, time claim, nonce và error riêng của provider tuân theo profile liên quan.
Danh tính service và lưu trữ credential
Traffic machine-to-machine nên định danh workload, không mạo danh một user tiện lợi. Ưu tiên workload identity của platform với short-lived credential, mTLS certificate hoặc OAuth client credentials cho confidential service. Scope mỗi identity vào đúng một workload và audience. Static API key đơn giản hơn nhưng identity và rotation yếu hơn; hash key khi lưu, chỉ hiển thị một lần, thêm prefix để lookup, rate-limit và hỗ trợ khoảng overlap khi xoay.
Dùng secret manager hoặc hardware-backed key service cho client secret và private key. Giới hạn truy cập, audit lần đọc, xoay an toàn và không commit credential vào source control hay bake vào image. Database nên giữ hash của session ID entropy cao, refresh token, recovery code và API key để read-only leak không lập tức biến thành bearer credential. Password là trường hợp khác: hash bằng hàm memory-hard chuyên cho password như Argon2id với parameter đã hiệu chỉnh và salt riêng cho từng password.
Trong browser, storage là lựa chọn theo threat model. localStorage, sessionStorage và IndexedDB đều bị XSS cùng origin đọc được. Cookie HttpOnly giấu value khỏi script nhưng được gửi tự động nên cần CSRF control. Backend-for-frontend có thể giữ OAuth token ở server và chỉ đưa hardened session cookie cho browser, thường làm nhỏ token-handling surface. Native application nên dùng secure credential store của operating system.
Ngăn XSS bằng output encoding đúng context, DOM API an toàn, sanitization cho HTML thực sự cần chấp nhận, dependency hygiene và Content Security Policy chặt. Đừng cho rằng HttpOnly khiến XSS vô hại: trong lúc chạy, malicious script vẫn có thể thực hiện action, đọc page data và thay đổi authorization request.
Lỗi phổ biến và hướng dẫn lựa chọn
| Lỗi | Hậu quả | Kiểm soát |
|---|---|---|
| Decode JWT mà không verify | Attacker tự cung cấp identity và scope | Kiểm tra signature, issuer, audience, algorithm, time và token type |
| Bearer token sống lâu trong browser storage | XSS tạo account takeover lâu dài | Lifetime ngắn; hardened cookie hoặc backend-for-frontend |
| Session ID không đổi sau login | Session fixation | Xoay atomically sau authentication và thay đổi đặc quyền |
| Mutation dùng cookie thiếu CSRF control | Cross-site action dưới session của nạn nhân | SameSite, Origin check và CSRF token |
| Thiếu object hoặc tenant check | Leo quyền ngang hoặc cross-tenant | Authorize từng resource theo principal |
| OAuth callback thiếu state/PKCE/nonce | Login CSRF, chặn code hoặc replay ID token | Sinh, lưu, kiểm tra, expire và consume từng value |
| Redirect URI nhận destination tùy ý | Lộ code hoặc token | Allowlist chính xác; không open redirect sau callback |
| Log chứa cookie hoặc token | Người có quyền đọc log replay credential | Redaction có cấu trúc và log policy được test |
Chọn server session cho first-party browser application khi cần revoke tức thời và semantics đơn giản. Chọn OAuth access token sống ngắn cho API được nhiều client hoặc service độc lập gọi, nhất là khi authorization server đã quản lý policy. Chọn OIDC khi client cần federated login. Chọn mTLS hoặc workload identity để service hạ tầng xác thực nhau. Các lựa chọn có thể cùng tồn tại: backend-for-frontend hoàn tất OIDC, giữ provider token ở server rồi phát opaque browser session riêng.
Đừng chọn JWT chỉ để bỏ một database lookup; phân phối key, claim cũ và revocation cũng là operational state. Đừng tự xây authorization server trừ khi triển khai identity protocol chính là sản phẩm: dùng provider trưởng thành và library được duy trì. Với mọi lựa chọn, hãy model credential theft, user bị disable, key rotation, clock skew, multi-region outage và logout trước production. Test negative case, không chỉ happy redirect.
Warning
Fail closed khi không thể kiểm tra credential hoặc kết quả mơ hồ. Không nhận token hết hạn vì identity service đang down, không fallback sang unsigned claim và không bỏ authorization để giữ availability. Thay vào đó, thiết kế cache có giới hạn và emergency procedure được ghi rõ.
Những điểm cần nhớ
- Authentication xác lập principal; authorization vẫn phải xét action, resource, tenant và policy hiện tại.
- Opaque server session rất hợp với first-party web. Dùng ID random mạnh, storage dạng hash, rotation, expiry rõ ràng và hardened cookie.
- Cookie giảm token exposure với JavaScript nhưng cần chống CSRF;
HttpOnlykhông vô hiệu hóa XSS. - JWT consumer phải kiểm tra signature, issuer, audience, algorithm, time và mục đích token. Decode không phải verify, còn revocation luôn là quyết định thiết kế.
- Giữ access token sống ngắn. Xoay refresh token kèm phát hiện reuse và overlap trusted key khi xoay signing key.
- OAuth ủy quyền truy cập API; OIDC bổ sung authentication. Authorization code với PKCE,
statevà OIDCnonceràng buộc mọi chặng của login transaction. - Dùng workload identity, mTLS hoặc scoped client credential cho service, đồng thời giữ bearer credential trong storage chuyên dụng được bảo vệ.
- Ưu tiên kiến trúc đơn giản nhất vẫn bảo toàn expiry và revocation, rồi test theft, replay, cross-tenant access, failure và rotation path.