Authentication & AuthorizationAuthentication & Authorization
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Hầu như mọi backend không tầm thường đều phải trả lời hai câu hỏi trên gần như mỗi request: “đây là ai?” và “họ có được phép làm việc này không?”. Câu đầu là authentication (AuthN), câu sau là authorization (AuthZ). Làm sai hai thứ này là con đường nhanh nhất dẫn tới lộ dữ liệu, để một user hành động thay danh nghĩa user khác, hoặc trao chìa khóa hệ thống cho kẻ tấn công — đó là lý do “Broken Access Control” và “Identification and Authentication Failures” đều nằm gần đỉnh của OWASP Top 10.
Đây là một hướng dẫn mang tính phòng thủ (defensive). Mục tiêu là giúp bạn xây hệ thống identity và access khó bị lạm dụng: lưu password sao cho một lần database bị dump không phải là “toang” hoàn toàn, phát hành token không thể giả mạo hay replay mãi mãi, và thực thi permission theo hướng fail closed. Note này nối tiếp các ý tưởng thiết kế API trong ./06-apis.md và gắn chặt với các biện pháp phòng thủ rộng hơn trong ./12-web-security.md.
AuthN và AuthZ — phân biệt cốt lõi
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Trả lời câu hỏi | Bạn là ai? | Bạn được phép làm gì? |
| Xảy ra | Trước (xác lập danh tính) | Sau khi đã biết danh tính |
| Tạo ra | Một identity / principal (user, service) | Quyết định cho phép / từ chối |
| Đầu vào điển hình | Password, token, certificate, biometric | Roles, attributes, quyền sở hữu, policies |
| Ví dụ lỗi | Kẻ tấn công đăng nhập thành người khác | Một user đã đăng nhập đọc được invoice của user khác |
| HTTP status | 401 Unauthorized (đặt tên nhầm — thực chất là “chưa authenticate”) | 403 Forbidden |
Một cách ghi nhớ hữu ích: AuthN là bước kiểm tra hộ chiếu ở cửa khẩu; AuthZ là chuyện visa của bạn có cho vào một căn phòng cụ thể hay không. Bạn luôn phải authenticate trước khi có thể authorize một cách có nghĩa, nhưng một danh tính hợp lệ không đồng nghĩa với việc có quyền — mỗi hành động được bảo vệ đều cần một lần kiểm tra AuthZ riêng, thực thi ở phía server.
Kiến thức nền tảng
Xử lý password (defensive)
Password là credential phổ biến nhất và cũng là thứ hay bị làm sai nhất. Nguyên tắc quan trọng nhất: bạn không bao giờ lưu password, và cũng không “encrypt” nó — bạn lưu một bản hash chậm và có salt.
Vì sao phải hash chứ không encrypt?
Encryption là có thể đảo ngược (reversible): bất kỳ ai có key đều có thể biến ciphertext trở lại password gốc. Điều đó khiến key trở thành một điểm chết người duy nhất, và bạn (hoặc kẻ tấn công lấy được key) có thể khôi phục password dạng plaintext của mọi user. Hashing là một chiều (one-way): từ hash bạn không thể khôi phục lại đầu vào một cách thực tế. Khi đăng nhập, bạn hash password người dùng gửi lên rồi so sánh với hash đã lưu. Bạn không bao giờ cần password gốc, nên bạn cũng không bao giờ nên có khả năng tạo ra nó.
Vì sao MD5 / SHA-256 dùng một mình là sai cho password
MD5, SHA-1, và cả SHA-256 đều là hash mật mã đa dụng, được thiết kế để chạy nhanh. Nhanh lại chính là điều sai cho password: một GPU hiện đại tính được hàng tỷ hash SHA-256 mỗi giây, nên kẻ tấn công lấy được bảng hash của bạn có thể brute-force hoặc dictionary-attack các password yếu gần như tức thì. Rainbow table (bảng tính trước) khiến các fast hash không salt trở nên dễ đảo ngược với những password thông dụng.
Password hashing cần tính chất ngược lại: phải cố tình chậm và memory-hard (tốn nhiều bộ nhớ), có thể điều chỉnh để giữ độ chậm khi phần cứng mạnh lên. Hãy dùng một password hashing function (còn gọi là KDF — key derivation function) được thiết kế cho việc này.
| Thuật toán | Loại | Ghi chú |
|---|---|---|
| Argon2id | KDF memory-hard | Người thắng Password Hashing Competition (2015). Lựa chọn mặc định được khuyến nghị hiện nay. Biến thể id chống cả side-channel lẫn tấn công GPU/ASIC. |
| scrypt | KDF memory-hard | Memory-hard, lựa chọn tốt khi không có Argon2. |
| bcrypt | Hash thích ứng (adaptive) | Đã được kiểm chứng từ 1999, hỗ trợ rộng rãi. Cắt đầu vào ở 72 byte — nên pre-hash đầu vào dài. Vững chắc, dù cũ hơn. |
| PBKDF2 | Hash lặp | Được FIPS chấp thuận, nhưng không memory-hard (yếu hơn trước GPU). Chỉ dùng khi compliance bắt buộc, với số iteration cao. |
| MD5 / SHA-1 / SHA-256 (dùng một mình) | Fast hash | Không bao giờ dùng cho password. Ổn cho checksum/toàn vẹn dữ liệu, không phải cho credential. |
Salt, pepper và work factor
- Salt — một giá trị ngẫu nhiên, duy nhất, sinh riêng cho mỗi password và lưu cùng với hash. Nó bảo đảm hai user có cùng password sẽ cho ra hash khác nhau, và vô hiệu hóa rainbow table. Salt không phải bí mật; nhiệm vụ của nó là tính duy nhất. Các function hiện đại như bcrypt/Argon2 tự sinh và nhúng salt cho bạn.
- Pepper — một giá trị bí mật thêm vào đầu vào trước khi hash, lưu tách biệt với database (ví dụ trong KMS/HSM hoặc config ứng dụng, không nằm trong DB). Nếu chỉ database bị lộ, pepper vẫn đứng chắn giữa kẻ tấn công và các hash. Đây là defense-in-depth, xếp chồng lên trên salt của từng password.
- Work factor (cost) — tham số điều chỉnh được làm cho việc hash chậm lại:
cost(rounds) của bcrypt, time/memory/parallelism của Argon2, số iteration của PBKDF2. Đặt sao cho một lần hash mất một khoảng thời gian đáng kể (một phần đáng kể của giây) trên phần cứng của bạn, và tăng dần theo thời gian khi phần cứng nhanh hơn. OWASP có công bố các tham số khuyến nghị cập nhật.
Một bản hash bcrypt đã lưu vốn đã mã hóa cả thuật toán, cost và salt trong một chuỗi:
- định danh thuật toán2b = bcrypt
- cost = 122^12 = 4096 iteration
- 22 ký tự salt (base64)
- 31 ký tự hash
Quy tắc bổ sung: so sánh hash theo constant time (dùng hàm verify của thư viện, không bao giờ dùng ==), áp dụng password policy hợp lý (ưu tiên độ dài hơn là độ phức tạp tùy tiện — NIST khuyến khích passphrase dài), kiểm tra password gửi lên với danh sách password đã lộ (ví dụ API k-anonymity của Have I Been Pwned), và rate-limit / khóa tạm khi thất bại liên tục.
Session-based (cookie-based) authentication
Mô hình cổ điển cho ứng dụng web trên browser: server giữ state, client chỉ giữ một ID mờ (opaque).
- User gửi credentials qua TLS.
- Server verify, rồi tạo một session record ở phía server (trong memory, Redis, hoặc DB) chứa user ID, roles, expiry, v.v.
- Server trả về một session ID — một giá trị ngẫu nhiên, dài, không thể đoán — trong header
Set-Cookie. - Browser tự động gửi cookie đó ở mọi request tiếp theo; server tra cứu session để nhận diện user.
- Khi logout (hoặc hết hạn), server xóa session record — revocation là tức thì và đơn giản vì server sở hữu state.
Vì cookie chỉ là một con trỏ ngẫu nhiên, nó không mang dữ liệu nào mà kẻ tấn công có thể đọc hay chỉnh sửa — niềm tin nằm ở kho lưu trữ phía server.
Các cookie flag quan trọng
| Flag | Mục đích |
|---|---|
HttpOnly | JavaScript không đọc được cookie (document.cookie), làm giảm nguy cơ đánh cắp token qua XSS. |
Secure | Cookie chỉ được gửi qua HTTPS, không bao giờ qua HTTP plaintext. |
SameSite=Lax / Strict | Hạn chế gửi cookie trên request cross-site, một biện pháp chống CSRF quan trọng. Strict an toàn nhất; Lax là mặc định phổ biến. |
Domain / Path | Giới hạn phạm vi cookie xuống host/path hẹp nhất cần dùng. |
Max-Age / Expires | Giới hạn vòng đời cookie; bỏ đi để tạo session cookie mất theo browser. |
Tiền tố __Host- | Tiền tố tên cookie bắt buộc Secure, Path=/ và không có Domain — một mặc định hardening mạnh. |
Set-Cookie: __Host-sid=9f2c...e71a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Cái giá của session auth là tính stateful: mỗi server cần truy cập được kho session, điều này quan trọng khi scale ngang (nên mới có các kho chia sẻ như Redis). Điểm mạnh lớn là revocation dễ dàng và không có dữ liệu nhạy cảm nằm ở client.
Token-based authentication (JWT)
Thay vì server nhớ session, server trao cho client một token đã ký (signed) chứa sẵn các claim. Ở mỗi request, client trình token (thường qua Authorization: Bearer <token>), và server verify chữ ký — không cần tra cứu. Đây là mô hình stateless, phù hợp cho API, mobile client và microservice không chia sẻ kho session chung.
Định dạng phổ biến nhất là JSON Web Token (JWT).
Cấu trúc JWT: header.payload.signature
Ba phần được encode base64url, nối bằng dấu chấm:
- headereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- payloadeyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9
- signaturedQw4w9WgXcQ7...signature...
Sau khi decode:
// Header — thuật toán & loại token
{
"alg": "HS256",
"typ": "JWT"
}
// Payload — các claim
{
"sub": "1234567890", // subject (user ID)
"name": "Alice",
"role": "admin",
"iss": "https://auth.example.com", // issuer (bên phát hành)
"aud": "https://api.example.com", // audience (bên nhận dự kiến)
"iat": 1700000000, // issued at (thời điểm phát hành, Unix time)
"exp": 1700003600, // expires (hết hạn, Unix time) — 1 giờ sau
"jti": "a3f1c9e2" // ID duy nhất của token (hữu ích cho danh sách revoke)
}
// Signature (về mặt khái niệm):
// HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
Điểm cực kỳ quan trọng: payload chỉ được encode base64, KHÔNG encrypt — ai cũng đọc được. Không bao giờ đặt bí mật (password, số thẻ, PII bạn không muốn lộ) vào JWT. Chữ ký bảo đảm tính toàn vẹn (integrity) (không ai sửa được claim), chứ không phải tính bí mật (confidentiality).
Ký (signing): HS256 và RS256
| HS256 (HMAC) | RS256 (RSA) | |
|---|---|---|
| Key | Một secret dùng chung | Key private ký, key public verify |
| Ai ký được | Bất kỳ ai có secret | Chỉ người giữ private key |
| Ai verify được | Bất kỳ ai có secret | Bất kỳ ai có public key |
| Phù hợp nhất | Một service vừa phát hành vừa verify | Nhiều service / bên thứ ba chỉ verify (ví dụ provider OAuth/OIDC) |
Lưu ý bảo mật: luôn ghim cứng (pin) thuật toán mong đợi ở phía verify. Tấn công alg: none khét tiếng và tấn công nhầm lẫn “RS256 bị verify như HS256, dùng public key làm secret cho HMAC” đều xuất phát từ việc tin vào header alg của chính token. Hãy verify với một allow-list thuật toán tường minh.
Claims, expiry và refresh token
Các registered claim chuẩn (iss, sub, aud, exp, nbf, iat, jti) được định nghĩa bởi RFC 7519; luôn validate exp (và lý tưởng là cả iss/aud) ở mỗi request. Giữ access token có thời hạn ngắn (vài phút tới một giờ) để một token bị đánh cắp cũng nhanh chóng hết hạn.
Để tránh bắt user đăng nhập lại vài phút một lần, ghép một access token ngắn hạn với một refresh token dài hạn:
- Access token — ngắn hạn, gửi ở mỗi API call, cấp quyền truy cập.
- Refresh token — dài hạn, chỉ gửi tới auth server để lấy access token mới. Lưu cẩn thận hơn (giá trị cao hơn), và dùng rotation: mỗi lần dùng sẽ phát hành refresh token mới và vô hiệu hóa cái cũ, nhờ đó một refresh token bị đánh cắp rồi dùng lại có thể bị phát hiện (reuse detection) và cả “họ” token bị revoke.
Lưu token ở phía client như thế nào
| Nơi lưu | Ưu điểm | Nhược điểm |
|---|---|---|
Cookie HttpOnly | JS không đọc được → chống đánh cắp qua XSS; tự động gửi kèm | Cần bảo vệ CSRF (SameSite, CSRF token) |
localStorage / sessionStorage | Đơn giản, hợp với SPA và header Authorization | Mọi JS đều đọc được → bất kỳ XSS nào = mất token; rủi ro XSS lớn hơn |
| Trong bộ nhớ (biến JS) | Không persist; mất khi refresh → cửa sổ đánh cắp nhỏ | Mất khi reload; thường ghép với một refresh cookie |
Mẫu hiện đại thực dụng cho browser: refresh token trong cookie HttpOnly, Secure, SameSite; access token giữ trong bộ nhớ. Đó là lý do việc lưu token là một quyết định bảo mật, không chỉ là chi tiết cài đặt.
Bài toán revocation
Session auth revoke tức thì (xóa record). JWT stateless thì hợp lệ cho tới khi hết hạn — đó vừa là điểm mấu chốt, vừa là toàn bộ vấn đề. Bạn không thể “thu hồi” một token đã ký. Các biện pháp giảm thiểu:
- Thời hạn ngắn để cửa sổ phơi nhiễm nhỏ.
- Một denylist / blocklist các
jtiđã revoke (kiểm tra ở mỗi request — nhưng điều này đưa state trở lại, làm mất một phần lợi thế stateless). - Xoay vòng (rotation) token/refresh kèm reuse detection.
- Xoay signing key (phương án hạt nhân: vô hiệu hóa toàn bộ token).
Sự đánh đổi này — stateless và revocation dễ dàng — chính là quyết định trung tâm khi chọn giữa session và token.
HTTP Basic authentication
Scheme đơn giản nhất (RFC 7617): client gửi Authorization: Basic <base64(username:password)> ở mỗi request.
Authorization: Basic YWxpY2U6czNjcjN0 # base64("alice:s3cr3t")
base64 là encoding, không phải encryption — đảo ngược cực kỳ dễ. Vì vậy Basic auth gửi password (về cơ bản là dạng rõ) trên từng request một. Nó chỉ chấp nhận được khi dùng qua TLS (HTTPS), và ngay cả khi đó cũng không hợp với ứng dụng cho end-user (không có logout, credential bị gửi lại liên tục, dễ vô tình bị ghi vào log). Nó ổn cho tooling nội bộ hoặc server-to-server đơn giản sau TLS, nhưng với auth cho user thật thì hãy ưu tiên token hoặc session.
Khái niệm chính
OAuth 2.0 — delegated authorization
OAuth 2.0 (RFC 6749) là một framework cho delegated authorization (ủy quyền truy cập): nó cho phép một user cấp cho ứng dụng bên thứ ba quyền truy cập giới hạn vào tài nguyên của họ trên một service khác mà không chia sẻ password. Khi bạn bấm “Sign in with Google” hay cho một app “đăng bài thay bạn”, đó là OAuth cấp quyền truy cập có scope thông qua một token. Lưu ý: OAuth nói về authorization (truy cập tài nguyên), không phải chủ yếu về authentication — để login/danh tính, bạn xếp chồng OpenID Connect lên trên (xem bên dưới).
Các vai trò (roles)
| Vai trò | Là ai |
|---|---|
| Resource Owner | User sở hữu dữ liệu. |
| Client | App yêu cầu truy cập thay mặt user. |
| Authorization Server | Phát hành token sau khi authenticate user & lấy consent (ví dụ auth server của Google). |
| Resource Server | API giữ tài nguyên được bảo vệ; chấp nhận access token. |
Token & scope
- Access token — credential ngắn hạn client gửi tới resource server. Thường (nhưng không luôn) là JWT.
- Refresh token — dùng để lấy access token mới mà không phải hỏi user lại.
- Scopes — các chuỗi cách nhau bằng dấu cách (
read:email,write:repo) giới hạn token được làm gì. Hãy thực thi least privilege: chỉ xin những scope thực sự cần.
Các grant type
| Grant | Trường hợp dùng |
|---|---|
| Authorization Code + PKCE | Mặc định cho app hướng user — web, SPA, mobile, native. PKCE nay được khuyến nghị cho mọi loại client. |
| Client Credentials | Machine-to-machine (không có user); client tự authenticate như chính nó. |
| Refresh Token | Đổi refresh token lấy access token mới. |
| Device Authorization | Thiết bị hạn chế nhập liệu (TV, CLI) — user authorize trên thiết bị thứ hai. |
| Đã deprecated — token nằm trong URL fragment quá dễ lộ. Dùng Auth Code + PKCE. | |
| Đã deprecated — app cầm password thô, đi ngược mục đích của OAuth. |
Authorization Code flow với PKCE — đi qua từng bước
PKCE (Proof Key for Code Exchange, RFC 7636) ngăn kẻ tấn công chặn được authorization code khỏi việc đổi nó lấy token, vì chỉ client gốc biết code_verifier bí mật.
Hai điểm bảo mật thiết yếu trong flow này: tham số state (một giá trị ngẫu nhiên được echo lại để chống CSRF trên bước redirect) và khớp redirect_uri chính xác (để code không thể bị chuyển hướng tới kẻ tấn công).
Client Credentials flow (machine-to-machine)
Không có user tham gia — một service tự authenticate như chính nó:
Service ──▶ POST /token
grant_type=client_credentials
client_id, client_secret, scope
◀── access_token
Service ──▶ GET /resource (Authorization: Bearer access_token) ──▶ Resource Server
OpenID Connect (OIDC) — identity xếp chồng trên OAuth
OAuth 2.0 cho resource server biết client được làm gì, nhưng không cho biết một cách đáng tin user là ai. OpenID Connect là một lớp identity mỏng nằm trên OAuth 2.0, chuẩn hóa phần authentication. Đây mới là thứ thực sự vận hành “Sign in with …”.
Bổ sung then chốt là ID token — một JWT mô tả user đã được authenticate, phát hành cùng với access token trong cùng Authorization Code flow. Trong khi access token của OAuth dành cho resource server, thì ID token dành cho client để biết ai vừa đăng nhập.
// Payload của ID token sau khi decode
{
"iss": "https://accounts.google.com", // ai phát hành
"sub": "10769150350006150715113082", // user ID duy nhất, ổn định
"aud": "your-client-id.apps...", // token này dành cho client CỦA BẠN
"email": "alice@example.com",
"email_verified": true,
"name": "Alice",
"iat": 1700000000,
"exp": 1700003600,
"nonce": "n-0S6_WzA2Mj" // buộc token với auth request của bạn (chống replay)
}
OIDC còn thêm: các claim chuẩn (email, name, picture), scope openid, endpoint UserInfo, và Discovery (/.well-known/openid-configuration) để client tự cấu hình. Hãy ưu tiên một provider/thư viện OIDC trưởng thành thay vì tự viết login.
SAML — SSO doanh nghiệp (tóm tắt)
SAML 2.0 (Security Assertion Markup Language) là chuẩn cũ hơn, dựa trên XML, cho federated single sign-on, vẫn thống trị trong môi trường enterprise/B2B. Các vai trò: Identity Provider (IdP) (ví dụ Okta, Azure AD) authenticate user và phát hành một assertion XML đã ký; Service Provider (SP) (app của bạn) tin assertion đó và cho user đăng nhập. Nó giải cùng bài toán “đăng nhập một lần, truy cập nhiều app” như OIDC, nhưng bằng XML và POST redirect qua browser thay vì JSON/JWT. Với app consumer/API mới, hãy ưu tiên OIDC; hỗ trợ SAML khi khách hàng doanh nghiệp yêu cầu.
Các mô hình authorization
Khi đã biết user là ai, bạn quyết định họ được làm gì. Các mô hình phổ biến:
RBAC — Role-Based Access Control
Permission được gom vào các role; user được gán role. “Admin xóa được; editor ghi được; viewer đọc được.” Đơn giản, phổ biến, dễ suy luận và audit. Có thể gặp role explosion (bùng nổ role) khi luật chi tiết dần (editor-of-team-A-in-region-EU).
- 01useralice
- 02rolesadmin
- 03permissions{read, write, delete}
- 04resource/articles
ABAC — Attribute-Based Access Control
Quyết định được tính từ attribute của subject, resource, action và environment. “Một bác sĩ được đọc hồ sơ nếu cùng khoa và đang trong ca trực.” Diễn đạt mạnh hơn RBAC nhiều và rất hợp với luật phụ thuộc ngữ cảnh, nhưng khó test và audit hơn.
allow if user.department == resource.department
and user.role == "doctor"
and env.time in shift_hours
ACL — Access Control List
Permission gắn trực tiếp vào từng resource: một danh sách (principal → action được phép). Nghĩ tới permission của filesystem hay “tài liệu này được chia sẻ cho Bob (view) và Carol (edit)”. Rất hợp cho chia sẻ theo từng object (kiểu Google Docs); không scale tốt như một policy toàn hệ thống.
ReBAC / policy engine
- ReBAC (Relationship-Based Access Control) — permission suy ra từ quan hệ trong một graph (“bạn sửa được tài liệu nếu bạn sở hữu folder chứa nó”). Được Google Zanzibar phổ biến; cài đặt bởi các công cụ như OpenFGA / SpiceDB.
- Policy engine — đưa AuthZ ra một engine chuyên biệt để luật nằm ngoài code ứng dụng. Ví dụ: Open Policy Agent (OPA) với ngôn ngữ Rego, hoặc XACML. Cách này tách policy khỏi code, tập trung việc audit, và cho phép đổi luật mà không cần redeploy.
| Mô hình | Độ chi tiết | Hợp nhất cho | Cần cẩn thận |
|---|---|---|---|
| RBAC | Thô (theo role) | Đa số app, chức năng công việc rõ ràng | Role explosion |
| ABAC | Mịn (theo attribute) | Luật phụ thuộc ngữ cảnh | Độ phức tạp, test |
| ACL | Theo từng object | Chia sẻ từng resource | Scale toàn hệ thống |
| ReBAC | Graph quan hệ | Sở hữu lồng nhau, chia sẻ | Chi phí mô hình hóa & hạ tầng |
Best Practices
So sánh các cơ chế authentication
| Cơ chế | State | Revocation | Hợp nhất cho | Rủi ro chính |
|---|---|---|---|---|
| Session + cookie | Stateful (kho ở server) | Dễ (xóa record) | App web render phía server | CSRF; scale kho session |
| JWT (bearer) | Stateless | Khó (tới khi hết hạn) | API, SPA, microservice | Đánh cắp token; không revoke được; lộ secret |
| Basic auth | Stateless | Không có (gửi credential mỗi lần) | Nội bộ/server-to-server qua TLS | Password gửi mỗi request |
| OAuth 2.0 | Dựa trên token | Qua refresh/expiry | Truy cập ủy quyền cho bên thứ ba | Cấu hình sai (redirect_uri, scope) |
| OIDC | Dựa trên token | Qua refresh/expiry | Federated login (“Sign in with…”) | Như OAuth + validate ID token |
| SAML | Dựa trên assertion | Session ở SP/IdP | SSO doanh nghiệp | Độ phức tạp XML, signature wrapping |
| API key | Chuỗi stateless | Xoay/revoke key | Auth service đơn giản | Sống lâu; lộ trong code/log |
| mTLS | Dựa trên certificate | Revoke/hết hạn cert | Service mesh zero-trust | Quản lý vòng đời cert |
Best practices chung
- Không bao giờ tự viết crypto hay auth. Dùng thư viện đã được kiểm chứng (bcrypt/Argon2, các SDK OAuth/OIDC uy tín) và, nếu được, một managed identity provider.
- Luôn qua TLS. Mọi credential, token và cookie phải đi qua HTTPS. Đặt
Securecho cookie; dùng HSTS. - Bắt buộc MFA (multi-factor authentication) cho mọi thứ nhạy cảm: thứ bạn biết (password) + thứ bạn có (app TOTP, hardware key). Ưu tiên các yếu tố chống phishing như WebAuthn/FIDO2 (passkey) hơn SMS OTP, vốn dễ bị SIM-swap và chặn bắt.
- Least privilege. Chỉ cấp role/scope tối thiểu cần thiết; mặc định là từ chối; yêu cầu cấp quyền tường minh. Mọi endpoint được bảo vệ đều kiểm tra AuthZ ở phía server — đừng bao giờ tin client để “giấu một cái nút”.
- Thực thi AuthZ ở mọi request, ở cấp object. Lỗi API số 1 (OWASP API Top 10) là BOLA / IDOR — tin một ID từ client mà không kiểm tra caller có sở hữu object đó không. Luôn verify
resource.owner == current_user(hoặc chạy policy check) cho mọi truy cập object. - Access token ngắn hạn + refresh token xoay vòng kèm reuse detection. Validate
exp,iss,aud, và ghim thuật toán ký ở mỗi JWT. - Xử lý secret an toàn. Giữ signing key, client secret và pepper trong secrets manager / KMS / HSM — không bao giờ trong source control, không trong code phía client, không trong log.
- Rate-limit và throttle các endpoint auth (login, token, password-reset, MFA). Thêm exponential backoff, khóa tài khoản một cách thận trọng (tránh DoS qua lockout), và CAPTCHA khi bị lạm dụng. Điều này làm giảm credential stuffing và brute force. (Xem ./12-web-security.md và ./06-apis.md.)
- Bảo vệ toàn bộ vòng đời tài khoản. Password reset an toàn (token dùng một lần, hết hạn, không đoán được; đừng tiết lộ email có tồn tại hay không), email verification, và vô hiệu hóa session/token khi đổi password hoặc logout.
- Log và giám sát các sự kiện authentication (đăng nhập, thất bại, thay đổi quyền, phát hành token) để phát hiện và ứng phó sự cố — nhưng không log secret hay token đầy đủ.
- Fail closed. Mọi lỗi trong một quyết định AuthZ đều phải từ chối. Một exception trong bước kiểm tra permission không bao giờ được vô tình cấp quyền.
- Ngăn user enumeration và rò rỉ qua timing. Trả về lỗi chung chung (“username hoặc password không đúng”), và dùng so sánh constant-time cho secret.
Tài liệu tham khảo
- OWASP Top 10 — gồm Broken Access Control & Identification/Authentication Failures
- OWASP Authentication Cheat Sheet
- OWASP Password Storage Cheat Sheet
- OWASP Authorization Cheat Sheet
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — PKCE (Proof Key for Code Exchange)
- RFC 7519 — JSON Web Token (JWT)
- OpenID Connect Core 1.0
- jwt.io — Giới thiệu & công cụ debug JWT
Part of the Backend Roadmap knowledge base.
Overview
Almost every non-trivial backend has to answer two questions on nearly every request: “who is this?” and “are they allowed to do this?”. The first is authentication (AuthN), the second is authorization (AuthZ). Getting these wrong is the fastest way to leak data, let one user act as another, or hand an attacker the keys to your system — which is why “Broken Access Control” and “Identification and Authentication Failures” both sit near the top of the OWASP Top 10.
This note is a defensive guide. The goal is to help you build identity and access systems that are hard to abuse: store passwords so that a database dump is not game over, issue tokens that cannot be forged or replayed forever, and enforce permissions that fail closed. It builds on the API design ideas in ./06-apis.md and pairs closely with the broader defenses in ./12-web-security.md.
AuthN vs AuthZ — the core distinction
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question answered | Who are you? | What are you allowed to do? |
| Happens | First (establishes identity) | After identity is known |
| Produces | An identity / principal (user, service) | An allow / deny decision |
| Typical inputs | Password, token, certificate, biometric | Roles, attributes, ownership, policies |
| Failure example | Attacker logs in as someone else | A logged-in user reads another user’s invoices |
| HTTP status | 401 Unauthorized (misnamed — really “unauthenticated”) | 403 Forbidden |
A useful mnemonic: AuthN is the passport check at the border; AuthZ is whether your visa lets you into a given room. You must always authenticate before you can meaningfully authorize, but a valid identity does not imply permission — every protected action needs its own AuthZ check, enforced on the server.
Fundamentals
Password handling (defensive)
Passwords are the most common credential and the most common thing done wrong. The single most important rule: you never store passwords, and you never “encrypt” them either — you store a slow, salted hash.
Why hash, not encrypt?
Encryption is reversible: anyone with the key can turn ciphertext back into the original password. That means the key becomes a single point of catastrophic failure, and you (or an attacker who steals the key) can recover every user’s plaintext password. Hashing is one-way: from the hash you cannot practically recover the input. At login you hash the submitted password and compare it to the stored hash. You never need the original, so you should never be able to produce it.
Why MD5 / SHA-256 alone are wrong for passwords
MD5, SHA-1, and even SHA-256 are general-purpose cryptographic hashes designed to be fast. Fast is exactly wrong for passwords: a modern GPU computes billions of SHA-256 hashes per second, so an attacker who steals your hash table can brute-force or dictionary-attack weak passwords almost instantly. Precomputed rainbow tables make unsalted fast hashes trivially reversible for common passwords.
Password hashing needs the opposite properties: it must be deliberately slow and memory-hard, tunable to stay slow as hardware improves. Use a password hashing function (a.k.a. a KDF — key derivation function) built for this.
| Algorithm | Type | Notes |
|---|---|---|
| Argon2id | Memory-hard KDF | Winner of the Password Hashing Competition (2015). Recommended default today. id variant resists both side-channel and GPU/ASIC attacks. |
| scrypt | Memory-hard KDF | Memory-hard, good choice where Argon2 isn’t available. |
| bcrypt | Adaptive hash | Battle-tested since 1999, widely available. Truncates input at 72 bytes — pre-hash long inputs. Solid, if older. |
| PBKDF2 | Iterated hash | FIPS-approved, but not memory-hard (weaker vs GPUs). Use only if compliance forces it, with a high iteration count. |
| MD5 / SHA-1 / SHA-256 (alone) | Fast hash | Never for passwords. Fine for checksums/integrity, not credentials. |
Salt, pepper, and work factor
- Salt — a unique, random value generated per password and stored alongside the hash. It ensures two users with the same password get different hashes, and it defeats rainbow tables. Salts are not secret; their job is uniqueness. Modern functions like bcrypt/Argon2 generate and embed the salt for you.
- Pepper — a secret value added to the input before hashing, stored separately from the database (e.g. in a KMS/HSM or app config, not in the DB). If only the database leaks, the pepper still stands between the attacker and the hashes. It is defense-in-depth, layered on top of per-password salts.
- Work factor (cost) — the tunable parameter that makes hashing slow: bcrypt’s
cost(rounds), Argon2’s time/memory/parallelism, PBKDF2’s iterations. Set it so a single hash takes a noticeable fraction of a second on your hardware, and raise it over time as hardware gets faster. OWASP publishes current recommended parameters.
A stored bcrypt hash already encodes the algorithm, cost, and salt in one string:
- algorithm identifier2b = bcrypt
- cost = 122^12 = 4096 iterations
- 22-char base64 salt
- 31-char hash
Additional rules: compare hashes in constant time (use the library’s verify function, never ==), enforce reasonable password policies (length over arbitrary complexity — NIST favors long passphrases), check submitted passwords against breached-password lists (e.g. Have I Been Pwned’s k-anonymity API), and rate-limit / lock out on repeated failures.
Session-based (cookie-based) authentication
The classic model for browser apps: the server keeps the state, the client holds only an opaque ID.
- User submits credentials over TLS.
- Server verifies them, then creates a session record server-side (in memory, Redis, or a DB) holding the user ID, roles, expiry, etc.
- Server returns a session ID — a long, random, unguessable value — in a
Set-Cookieheader. - The browser automatically sends that cookie on every subsequent request; the server looks up the session to identify the user.
- On logout (or expiry) the server deletes the session record — revocation is instant and simple because the server owns the state.
Because the cookie is just a random pointer, it carries no data an attacker can read or tamper with — the trust lives in the server-side store.
Cookie flags that matter
| Flag | Purpose |
|---|---|
HttpOnly | JavaScript cannot read the cookie (document.cookie), blunting XSS-based token theft. |
Secure | Cookie is sent only over HTTPS, never plaintext HTTP. |
SameSite=Lax / Strict | Restricts sending the cookie on cross-site requests, a key CSRF defense. Strict is safest; Lax is a common default. |
Domain / Path | Scope the cookie to the narrowest host/path that needs it. |
Max-Age / Expires | Bound the cookie’s lifetime; omit for a session cookie that dies with the browser. |
__Host- prefix | A cookie name prefix that forces Secure, Path=/, and no Domain — a strong hardening default. |
Set-Cookie: __Host-sid=9f2c...e71a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Session auth’s cost is statefulness: every server needs to reach the session store, which matters when scaling horizontally (hence shared stores like Redis). Its big win is easy revocation and no sensitive data on the client.
Token-based authentication (JWT)
Instead of the server remembering the session, the server hands the client a signed token that contains the claims. On each request the client presents the token (usually Authorization: Bearer <token>), and the server verifies the signature — no lookup required. This is stateless, which suits APIs, mobile clients, and microservices that don’t share a session store.
The most common format is the JSON Web Token (JWT).
JWT structure: header.payload.signature
Three base64url-encoded parts joined by dots:
- headereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- payloadeyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9
- signaturedQw4w9WgXcQ7...signature...
Decoded:
// Header — algorithm & token type
{
"alg": "HS256",
"typ": "JWT"
}
// Payload — the claims
{
"sub": "1234567890", // subject (the user ID)
"name": "Alice",
"role": "admin",
"iss": "https://auth.example.com", // issuer
"aud": "https://api.example.com", // audience
"iat": 1700000000, // issued at (Unix time)
"exp": 1700003600, // expires (Unix time) — 1 hour later
"jti": "a3f1c9e2" // unique token ID (useful for revocation lists)
}
// Signature (conceptually):
// HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
Critical point: the payload is only base64-encoded, not encrypted — anyone can read it. Never put secrets (passwords, card numbers, PII you wouldn’t expose) in a JWT. The signature guarantees integrity (nobody altered the claims), not confidentiality.
Signing: HS256 vs RS256
| HS256 (HMAC) | RS256 (RSA) | |
|---|---|---|
| Keys | One shared secret | Private key signs, public key verifies |
| Who can sign | Anyone with the secret | Only the private-key holder |
| Who can verify | Anyone with the secret | Anyone with the public key |
| Best for | Single service that both issues & verifies | Many services / third parties that only verify (e.g. OAuth/OIDC providers) |
Security note: always pin the expected algorithm on the verifier. The infamous alg: none attack and the “RS256 verified as HS256 using the public key as the HMAC secret” confusion attack both come from trusting the token’s own alg header. Verify with an explicit allow-list of algorithms.
Claims, expiry, and refresh tokens
Standard registered claims (iss, sub, aud, exp, nbf, iat, jti) are defined by RFC 7519; always validate exp (and ideally iss/aud) on every request. Keep access tokens short-lived (minutes to an hour) so a stolen token expires quickly.
To avoid making the user log in every few minutes, pair a short-lived access token with a long-lived refresh token:
- Access token — short-lived, sent on every API call, grants access.
- Refresh token — long-lived, sent only to the auth server to obtain a new access token. Store it more carefully (it is higher value), and use rotation: each use issues a new refresh token and invalidates the old one, so a stolen-and-reused refresh token can be detected (reuse detection) and the whole family revoked.
Where to store tokens on the client
| Storage | Pros | Cons |
|---|---|---|
HttpOnly cookie | JS can’t read it → resists XSS theft; sent automatically | Needs CSRF protection (SameSite, CSRF tokens) |
localStorage / sessionStorage | Simple, works well for SPAs and Authorization header | Readable by any JS → any XSS = token theft; no automatic CSRF but bigger XSS risk |
| In-memory (JS variable) | Not persisted; gone on refresh → smaller theft window | Lost on reload; usually paired with a refresh cookie |
The pragmatic modern pattern for browsers: refresh token in an HttpOnly, Secure, SameSite cookie; access token kept in memory. This is why token storage is a security decision, not just an implementation detail.
The revocation problem
Session auth revokes instantly (delete the record). Stateless JWTs are valid until they expire — that’s the whole point, and the whole problem. You cannot “un-issue” a signed token. Mitigations:
- Short expiry so the exposure window is small.
- A denylist / blocklist of revoked
jtis (checked on each request — but this reintroduces state, partially defeating the stateless benefit). - Token/refresh rotation with reuse detection.
- Rotating the signing key (nuclear option: invalidates all tokens).
This tradeoff — statelessness vs easy revocation — is the central decision when choosing sessions vs tokens.
HTTP Basic authentication
The simplest scheme (RFC 7617): the client sends Authorization: Basic <base64(username:password)> on each request.
Authorization: Basic YWxpY2U6czNjcjN0 # base64("alice:s3cr3t")
base64 is encoding, not encryption — trivially reversible. So Basic auth sends the password (effectively in the clear) on every single request. It is acceptable only over TLS (HTTPS), and even then it’s poorly suited to user-facing apps (no logout, credentials resent constantly, easy to log by accident). It’s fine for simple server-to-server or internal tooling behind TLS, but prefer tokens or sessions for real user auth.
Key Concepts
OAuth 2.0 — delegated authorization
OAuth 2.0 (RFC 6749) is a framework for delegated authorization: it lets a user grant a third-party application limited access to their resources on another service without sharing their password. When you click “Sign in with Google” or let an app “post on your behalf”, that’s OAuth granting scoped access via a token. Note: OAuth is about authorization (access to resources), not primarily authentication — for login/identity you layer OpenID Connect on top (see below).
Roles
| Role | Who it is |
|---|---|
| Resource Owner | The user who owns the data. |
| Client | The app requesting access on the user’s behalf. |
| Authorization Server | Issues tokens after authenticating the user & getting consent (e.g. Google’s auth server). |
| Resource Server | The API holding the protected resources; accepts access tokens. |
Tokens & scopes
- Access token — short-lived credential the client sends to the resource server. Often (but not always) a JWT.
- Refresh token — used to get new access tokens without re-prompting the user.
- Scopes — space-separated strings (
read:email,write:repo) that bound what the token may do. Enforce least privilege: request only the scopes you need.
Grant types
| Grant | Use case |
|---|---|
| Authorization Code + PKCE | The default for user-facing apps — web, SPA, mobile, native. PKCE is now recommended for all clients. |
| Client Credentials | Machine-to-machine (no user); the client authenticates as itself. |
| Refresh Token | Exchange a refresh token for a fresh access token. |
| Device Authorization | Input-constrained devices (TVs, CLIs) — user authorizes on a second device. |
| Deprecated — tokens in the URL fragment leak too easily. Use Auth Code + PKCE. | |
| Deprecated — the app handles the raw password, defeating OAuth’s purpose. |
Authorization Code flow with PKCE — walkthrough
PKCE (Proof Key for Code Exchange, RFC 7636) stops an attacker who intercepts the authorization code from redeeming it, because only the original client knows the secret code_verifier.
Two security essentials in this flow: the state parameter (a random value echoed back to defend against CSRF on the redirect) and exact redirect_uri matching (so codes can’t be redirected to an attacker).
Client Credentials flow (machine-to-machine)
No user involved — a service authenticates as itself:
Service ──▶ POST /token
grant_type=client_credentials
client_id, client_secret, scope
◀── access_token
Service ──▶ GET /resource (Authorization: Bearer access_token) ──▶ Resource Server
OpenID Connect (OIDC) — identity on top of OAuth
OAuth 2.0 tells the resource server what a client may do, but not reliably who the user is. OpenID Connect is a thin identity layer on top of OAuth 2.0 that standardizes authentication. It’s what actually powers “Sign in with …”.
The key addition is the ID token — a JWT describing the authenticated user, issued alongside the access token in the same Authorization Code flow. Where an OAuth access token is meant for the resource server, the ID token is meant for the client to learn who logged in.
// Decoded ID token payload
{
"iss": "https://accounts.google.com", // who issued it
"sub": "10769150350006150715113082", // stable unique user ID
"aud": "your-client-id.apps...", // this token is for YOUR client
"email": "alice@example.com",
"email_verified": true,
"name": "Alice",
"iat": 1700000000,
"exp": 1700003600,
"nonce": "n-0S6_WzA2Mj" // ties token to your auth request (replay defense)
}
OIDC also adds: standard claims (email, name, picture), the openid scope, a UserInfo endpoint, and Discovery (/.well-known/openid-configuration) so clients can auto-configure. Prefer a mature OIDC provider/library over rolling your own login.
SAML — enterprise SSO (brief)
SAML 2.0 (Security Assertion Markup Language) is the older, XML-based standard for federated single sign-on, still dominant in enterprise/B2B. Its roles: the Identity Provider (IdP) (e.g. Okta, Azure AD) authenticates the user and issues a signed XML assertion; the Service Provider (SP) (your app) trusts that assertion and logs the user in. It solves the same “log in once, access many apps” problem as OIDC, but with XML and browser POST redirects instead of JSON/JWT. For new consumer/API apps, prefer OIDC; support SAML when enterprise customers require it.
Authorization models
Once you know who the user is, you decide what they can do. Common models:
RBAC — Role-Based Access Control
Permissions are grouped into roles; users get roles. “Admins can delete; editors can write; viewers can read.” Simple, ubiquitous, easy to reason about and audit. Can suffer role explosion when rules get fine-grained (editor-of-team-A-in-region-EU).
- 01useralice
- 02rolesadmin
- 03permissions{read, write, delete}
- 04resource/articles
ABAC — Attribute-Based Access Control
Decisions are computed from attributes of the subject, resource, action, and environment. “A doctor may read a record if they are in the same department and it’s during a shift.” Far more expressive than RBAC and great for context-aware rules, but harder to test and audit.
allow if user.department == resource.department
and user.role == "doctor"
and env.time in shift_hours
ACL — Access Control List
Permissions attached directly to each resource: a list of (principal → allowed actions). Think filesystem permissions or “this document is shared with Bob (view) and Carol (edit)”. Great for per-object sharing (Google Docs style); doesn’t scale well as a system-wide policy.
ReBAC / policy engines
- ReBAC (Relationship-Based Access Control) — permissions derived from relationships in a graph (“you can edit a doc if you own the folder it’s in”). Popularized by Google Zanzibar; implemented by tools like OpenFGA / SpiceDB.
- Policy engines — externalize AuthZ into a dedicated engine so rules live outside app code. Examples: Open Policy Agent (OPA) with the Rego language, or XACML. This separates policy from code, centralizes auditing, and lets you change rules without redeploying.
| Model | Granularity | Best for | Watch out for |
|---|---|---|---|
| RBAC | Coarse (roles) | Most apps, clear job functions | Role explosion |
| ABAC | Fine (attributes) | Context-dependent rules | Complexity, testing |
| ACL | Per-object | Sharing individual resources | System-wide scaling |
| ReBAC | Relationship graph | Nested ownership, sharing | Modeling & infra overhead |
Best Practices
Comparison of authentication mechanisms
| Mechanism | State | Revocation | Best for | Main risk |
|---|---|---|---|---|
| Session + cookie | Stateful (server store) | Easy (delete record) | Server-rendered web apps | CSRF; store scaling |
| JWT (bearer) | Stateless | Hard (until expiry) | APIs, SPAs, microservices | Token theft; can’t revoke; secret leakage |
| Basic auth | Stateless | N/A (credentials each time) | Internal/server-to-server over TLS | Password sent every request |
| OAuth 2.0 | Token-based | Via refresh/expiry | Delegated 3rd-party access | Misconfig (redirect_uri, scopes) |
| OIDC | Token-based | Via refresh/expiry | Federated login (“Sign in with…”) | Same as OAuth + ID-token validation |
| SAML | Assertion-based | Session at SP/IdP | Enterprise SSO | XML complexity, signature wrapping |
| API keys | Stateless string | Rotate/revoke key | Simple service auth | Long-lived; leaks in code/logs |
| mTLS | Certificate-based | Revoke/expire cert | Zero-trust service mesh | Cert lifecycle management |
General best practices
- Never roll your own crypto or auth. Use vetted libraries (bcrypt/Argon2 implementations, established OAuth/OIDC SDKs) and, where possible, a managed identity provider.
- Always over TLS. Every credential, token, and cookie must travel over HTTPS only. Set
Secureon cookies; use HSTS. - Enforce MFA (multi-factor authentication) for anything sensitive: something you know (password) + something you have (TOTP app, hardware key). Prefer phishing-resistant factors like WebAuthn/FIDO2 (passkeys) over SMS OTP, which is vulnerable to SIM-swap and interception.
- Least privilege. Grant the minimum roles/scopes needed; default to deny; require explicit grants. Every protected endpoint checks AuthZ server-side — never trust the client to hide a button.
- Enforce AuthZ on every request, at the object level. The #1 API flaw (OWASP API Top 10) is BOLA / IDOR — trusting an ID from the client without checking the caller owns that object. Always verify
resource.owner == current_user(or run the policy check) for every object access. - Short-lived access tokens + rotating refresh tokens with reuse detection. Validate
exp,iss,aud, and pin the signing algorithm on every JWT. - Secure secret handling. Keep signing keys, client secrets, and peppers in a secrets manager / KMS / HSM — never in source control, never in client-side code, never in logs.
- Rate-limit and throttle auth endpoints (login, token, password-reset, MFA). Add exponential backoff, account lockout with care (avoid DoS via lockout), and CAPTCHAs on abuse. This blunts credential stuffing and brute force. (See ./12-web-security.md and ./06-apis.md.)
- Protect the whole account lifecycle. Secure password reset (single-use, expiring, unguessable tokens; don’t reveal whether an email exists), email verification, and session/token invalidation on password change or logout.
- Log and monitor authentication events (logins, failures, privilege changes, token issuance) for detection and incident response — without logging secrets or full tokens.
- Fail closed. On any error in an AuthZ decision, deny. An exception in a permission check must never accidentally grant access.
- Prevent user enumeration and timing leaks. Return generic errors (“invalid username or password”), and use constant-time comparisons for secrets.
References
- OWASP Top 10 — including Broken Access Control & Identification/Authentication Failures
- OWASP Authentication Cheat Sheet
- OWASP Password Storage Cheat Sheet
- OWASP Authorization Cheat Sheet
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — PKCE (Proof Key for Code Exchange)
- RFC 7519 — JSON Web Token (JWT)
- OpenID Connect Core 1.0
- jwt.io — JWT introduction & debugger