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.

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

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.

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

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

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

sequenceDiagram participant B as Browser participant C as Client backend participant A as Authorization server participant R as Resource server B->>C: Bắt đầu đăng nhập C->>C: Lưu state, nonce, PKCE verifier C-->>B: Redirect cùng state, nonce, PKCE challenge B->>A: Xác thực và chấp thuận A-->>B: Redirect cùng code và state B->>C: Callback cùng code và state C->>C: Kiểm tra rồi consume state C->>A: Code cùng PKCE verifier A-->>C: ID token, access token, refresh token nếu có C->>C: Kiểm tra signature, claim và nonce của ID token C->>R: Access token R-->>C: Resource đã được authorize

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; HttpOnly khô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, state và OIDC nonce rà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.