Node.js Super Senior · Phase 5 — Authentication & Security
Phase 5: lock down your API — password hashing with bcrypt/argon2, sessions vs JWT (anatomy, refresh tokens, rotation), secure cookies, OAuth2/OIDC, RBAC and ownership checks, and defenses against XSS, CSRF and injection.
This is Phase 5 of the 10-phase Super Senior path {Đây là Phase 5 của lộ trình Super Senior 10 phase}. Your API works and persists data {API của bạn chạy và lưu dữ liệu}. Now we make it safe for the open internet, where every endpoint is probed within minutes of going live {Giờ ta làm nó an toàn trên internet mở, nơi mọi endpoint bị dò chỉ vài phút sau khi lên}. Security is where shortcuts cause breaches — a senior treats it as a default, not a feature {Bảo mật là nơi đi tắt gây rò rỉ — senior coi nó là mặc định, không phải tính năng}.
First, the two words people blur {Trước hết, hai từ người ta hay nhập nhằng}:
AUTHENTICATION (authn) AUTHORIZATION (authz)
"Who are you?" "Are you allowed to do this?"
prove identity (login) check permissions (role / ownership)
│ │
▼ ▼
issue a session/token gate the route / the row
Confusing them is the #1 source of access-control bugs {Nhầm chúng là nguồn lỗi kiểm soát truy cập số 1}.
5.0 The Node auth library landscape {Bản đồ thư viện auth của Node}
You don’t roll your own crypto — you assemble vetted libraries {Bạn không tự viết crypto — bạn lắp ráp các thư viện đã kiểm chứng}. Know what each one is for {Biết mỗi cái dùng để làm gì}:
| Library {Thư viện} | Job {Việc} |
|---|---|
bcrypt / argon2 | hash + verify passwords (slow, salted) {hash mật khẩu} |
jsonwebtoken | sign + verify JWTs {ký + xác minh JWT} |
passport (+ strategies) | pluggable auth middleware (local, JWT, OAuth) {middleware auth cắm được} |
express-session | server-side sessions (cookie holds an id) {session phía server} |
connect-redis | store sessions in Redis (multi-server) {lưu session trong Redis} |
cookie-parser | read/sign cookies {đọc/ký cookie} |
helmet | set ~15 security headers {đặt ~15 header bảo mật} |
express-rate-limit | throttle abuse / brute force {kìm lạm dụng} |
csrf-csrf / csrf-sync | CSRF tokens for cookie auth {token CSRF} |
zod | validate + type all input {validate + type input} |
The mental split {Phân chia tư duy}: bcrypt/argon2 protect the password at rest; jsonwebtoken/express-session carry identity between requests; helmet/rate-limit/zod/csrf defend the perimeter {bcrypt/argon2 bảo vệ mật khẩu khi lưu; jsonwebtoken/express-session mang danh tính giữa các request; helmet/rate-limit/zod/csrf phòng thủ vành đai}.
5.1 Password hashing — get this exactly right {Hash mật khẩu — phải đúng chính xác}
You never store a password, even encrypted (encryption is reversible) — you store a one-way hash {Bạn không bao giờ lưu mật khẩu, kể cả mã hóa (mã hóa đảo ngược được) — bạn lưu một hash một chiều}. And not a fast hash like sha256 — fast is bad here, because an attacker who steals the DB can try billions of guesses per second {Và không phải hash nhanh như sha256 — nhanh là dở, vì kẻ trộm DB có thể thử hàng tỷ lần đoán mỗi giây}. Use a deliberately slow, salted, password-specific algorithm: bcrypt or argon2 {Dùng thuật toán chậm, có salt, dành riêng cho mật khẩu: bcrypt hoặc argon2}.
import bcrypt from 'bcrypt';
// On register: cost factor 12 ≈ ~250ms per hash (tune to your hardware)
const hash = await bcrypt.hash(plainPassword, 12);
// On login: compare is constant-time and reads the salt+cost FROM the hash string
const ok = await bcrypt.compare(attempt, user.passwordHash);
What a bcrypt hash actually contains — the salt and cost travel inside it {Một hash bcrypt thực chất chứa gì — salt và cost đi bên trong nó}:
$2b$12$eImiTXuWVxfM37uY4JANjQ.gT1f... ← stored in the DB
│ │ └ 22-char salt + 31-char hash
│ └ cost factor (2^12 = 4096 rounds)
└ algorithm version (bcrypt)
Senior details that separate you from a tutorial {Chi tiết senior tách bạn khỏi tutorial}:
- Salt defeats rainbow tables — a unique random salt per user means identical passwords hash differently. bcrypt/argon2 generate and embed it automatically {salt đánh bại rainbow table — salt ngẫu nhiên riêng mỗi user; bcrypt/argon2 tự sinh và nhúng}.
- Cost factor is a dial — raise it as hardware gets faster; aim for ~250ms per hash {cost factor là núm vặn — tăng khi phần cứng nhanh hơn}.
- bcrypt truncates at 72 bytes — pre-hash with SHA-256 if you must allow very long passphrases {bcrypt cắt ở 72 byte — pre-hash bằng SHA-256 nếu cho phép passphrase rất dài}.
- argon2id is the current best — memory-hard, resists GPU cracking; prefer it for new systems {argon2id tốt nhất hiện nay — khó về bộ nhớ, kháng GPU; ưu tiên cho hệ thống mới}.
import argon2 from 'argon2';
const hash = await argon2.hash(plainPassword, { type: argon2.argon2id });
const ok = await argon2.verify(user.passwordHash, attempt);
Anti-enumeration {Chống dò tài khoản}: return the same generic error for “no such user” and “wrong password”, and ideally take the same time — otherwise attackers learn which emails exist {trả cùng một lỗi chung cho “không có user” và “sai mật khẩu”, và lý tưởng là cùng thời gian — nếu không kẻ tấn công biết email nào tồn tại}.
5.2 Cookies — the secure-by-default recipe {Cookie — công thức an toàn mặc định}
Whether you use sessions or JWT, the cookie is usually how the credential rides between browser and server {Dù dùng session hay JWT, cookie thường là cách chứng chỉ đi giữa trình duyệt và server}. Each attribute blocks a specific attack {Mỗi thuộc tính chặn một kiểu tấn công cụ thể}:
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
│ │ │ │ │
JS can't read it ◄────┘ │ │ │ └ lifetime (seconds)
(blunts XSS theft) │ │ └ which paths send it
HTTPS only ◄───────────────────┘ └ not sent on cross-site requests
(no plaintext leak) (blunts CSRF)
| Attribute {Thuộc tính} | Protects against {Chống} |
|---|---|
HttpOnly | XSS reading the token via document.cookie |
Secure | leaking over plain HTTP {rò qua HTTP thường} |
SameSite=Lax/Strict | CSRF (cross-site auto-sending) {CSRF} |
__Host- name prefix | cookie fixation / subdomain tampering {giả mạo} |
res.cookie('sid', id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // 'strict' for max safety; 'none' (with Secure) only for cross-site
maxAge: 86_400_000, // ms in Express
});
Golden rule {Quy tắc vàng}: never store auth tokens in
localStorage— any XSS can read it. AnHttpOnlycookie is invisible to JavaScript {đừng bao giờ lưu token auth tronglocalStorage— XSS nào cũng đọc được. CookieHttpOnlyvô hình với JS}.
5.3 Sessions (stateful) {Session (có trạng thái)}
The classic web-app model: the server keeps the session data; the cookie only holds an opaque id {Mô hình web kinh điển: server giữ dữ liệu session; cookie chỉ giữ một id mờ}.
LOGIN LATER REQUEST
browser ──email+pw──▶ server browser ──Cookie: sid=abc──▶ server
verify hash look up abc
create session abc ──▶ Redis in Redis ──▶ user
◄─Set-Cookie: sid=abc── ◄── response ──
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { Redis } from 'ioredis';
app.use(session({
store: new RedisStore({ client: new Redis(process.env.REDIS_URL) }), // shared across servers
secret: process.env.SESSION_SECRET!, // signs the cookie so it can't be forged
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86_400_000 },
}));
The superpower of sessions: instant revocation — delete the session in Redis and the user is logged out everywhere, immediately {Siêu năng lực của session: thu hồi tức thì — xóa session trong Redis là user đăng xuất mọi nơi ngay lập tức}. The cost: a store lookup per request and a shared store across instances {Cái giá: một lần tra store mỗi request và một store dùng chung}.
5.4 JWT (stateless) — anatomy first {JWT (không trạng thái) — giải phẫu trước}
A JWT is three Base64URL parts joined by dots: header.payload.signature {JWT là ba phần Base64URL nối bằng dấu chấm: header.payload.signature}.
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMiLCJleHAiOjE3...} . 3Txs9...K2
└──── HEADER ────────┘ └──────── PAYLOAD ───────────┘ └─ SIGNATURE ─┘
{ "alg":"HS256", { "sub":"123", HMAC_SHA256(
"typ":"JWT" } "role":"admin", header.payload,
"iat":1700000000, secret )
"exp":1700604800 }
Two facts that prevent every JWT mistake {Hai sự thật ngăn mọi lỗi JWT}:
- The payload is signed, not encrypted — anyone can Base64-decode and read it. Never put secrets (passwords, card numbers) in a JWT {payload được ký, không mã hóa — ai cũng decode đọc được. Đừng để bí mật trong JWT}.
- The signature proves integrity — change one character of the payload and the signature no longer matches your secret, so the server rejects it {chữ ký chứng minh toàn vẹn — đổi một ký tự payload là chữ ký không khớp secret nữa, server từ chối}.
The standard claims {Các claim chuẩn}: sub (subject/user id), iat (issued at), exp (expiry), iss (issuer), aud (audience) {…}.
import jwt from 'jsonwebtoken';
const accessToken = jwt.sign(
{ sub: user.id, role: user.role }, // payload (claims)
process.env.JWT_SECRET!, // secret (HS256) — or a private key (RS256)
{ expiresIn: '15m', issuer: 'api.example.com' },
);
// Verifying throws on bad signature OR expiry — always wrap in try/catch
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, { issuer: 'api.example.com' });
} catch (err) {
// TokenExpiredError | JsonWebTokenError → respond 401
}
HS256 vs RS256 {HS256 vs RS256}: HS256 uses one shared secret (signer and verifier are the same party) {HS256 dùng một secret chung}. RS256 uses a private key to sign and a public key to verify — so many services can verify without holding the signing key (the model for OAuth/OIDC) {RS256 dùng private key để ký và public key để xác minh — nhiều service xác minh mà không giữ khóa ký (mô hình OAuth/OIDC)}.
Access + refresh tokens — the production pattern {Access + refresh token — mẫu production}
A short-lived access token limits the damage if it leaks; a long-lived refresh token (stored in an HttpOnly cookie) silently gets new ones {Access token ngắn hạn giới hạn thiệt hại khi rò; refresh token dài hạn (trong cookie HttpOnly) lấy token mới âm thầm}.
access token : 15 min ── used on every API call (Authorization: Bearer ...)
refresh token : 7 days ── HttpOnly cookie, used ONLY at /auth/refresh
└ stored server-side (Redis) so it can be revoked
// /auth/refresh — rotate: issue a new pair AND invalidate the old refresh token
app.post('/auth/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
const payload = jwt.verify(token, process.env.REFRESH_SECRET!) as { sub: string; jti: string };
// Rotation + reuse-detection: the jti must still be the "current" one in Redis
const current = await redis.get(`refresh:${payload.sub}`);
if (current !== payload.jti) {
await redis.del(`refresh:${payload.sub}`); // reuse detected → revoke the whole family
return res.status(401).json({ error: 'Token reuse detected' });
}
// ...issue new access + new refresh, store new jti, set the cookie...
});
The honest trade-off {Đánh đổi thật}: pure JWTs can’t be revoked before expiry {JWT thuần không thu hồi được trước hạn}. The moment you add a refresh denylist for instant logout, you’ve reintroduced server state — which is fine, just be honest that “stateless” became “mostly stateless” {Khi bạn thêm denylist để đăng xuất tức thì là đã tái lập state — ổn thôi, chỉ cần thành thật rằng “stateless” đã thành “gần như stateless”}.
Sessions vs JWT — choose deliberately {Session vs JWT — chọn có cân nhắc}
| Sessions (stateful) | JWT (stateless) | |
|---|---|---|
| State lives {State ở} | server (Redis) | inside the token |
| Revoke instantly {Thu hồi ngay} | ✅ delete session | ❌ valid until expiry |
| Per-request cost {Chi phí/request} | store lookup | just verify signature |
| Multi-server {Đa server} | needs shared store | ✅ none needed |
| Best for {Hợp cho} | web apps | APIs, mobile, microservices |
The dangerous myth {Lầm tưởng nguy hiểm}: JWTs aren’t “more secure” — they’re harder to revoke {JWT không “an toàn hơn” — chúng khó thu hồi hơn}.
5.5 Passport.js — pluggable strategies {Passport.js — strategy cắm được}
Passport unifies all of the above behind strategies (one per method) {Passport hợp nhất tất cả sau strategy (mỗi phương thức một cái)}.
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import bcrypt from 'bcrypt';
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
const user = await User.findOne({ where: { email } });
// Same generic failure for "no user" and "wrong password" → no enumeration.
if (!user || !(await bcrypt.compare(password, user.passwordHash))) return done(null, false);
return done(null, user);
},
));
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET!,
}, async (payload: { sub: string }, done) => {
const user = await User.findByPk(payload.sub);
return user ? done(null, user) : done(null, false);
}));
5.6 OAuth2 / OIDC — “Login with Google” {OAuth2 / OIDC — “Đăng nhập với Google”}
OAuth2 lets users log in via a provider without giving you their password {OAuth2 cho user đăng nhập qua provider mà không đưa bạn mật khẩu}. The modern flow is Authorization Code + PKCE {Luồng hiện đại là Authorization Code + PKCE}:
1. your app ──redirect──▶ Google consent screen
2. user approves
3. Google ──redirect to callbackURL?code=XYZ──▶ your app
4. your server ──exchange code (+ secret/PKCE) for tokens──▶ Google
5. you get an id_token (OIDC) → find-or-create a local user → issue YOUR session/JWT
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: '/auth/google/callback',
}, async (_at, _rt, profile, done) => {
const [user] = await User.findOrCreate({
where: { googleId: profile.id },
defaults: { email: profile.emails?.[0]?.value, name: profile.displayName },
});
return done(null, user);
}));
OIDC (OpenID Connect) is the thin identity layer on top of OAuth2 that adds the
id_token(a JWT describing the user). “Login with X” is OIDC {OIDC là tầng danh tính mỏng trên OAuth2, thêmid_token(một JWT mô tả user). “Đăng nhập với X” là OIDC}.
5.7 Authorization — RBAC, permissions & ownership {Phân quyền — RBAC, permission & sở hữu}
Role-Based Access Control gates routes by role {Phân quyền theo vai trò chặn route theo role}:
const authorize = (...roles: string[]): RequestHandler => (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
app.delete('/users/:id', requireAuth, authorize('admin'), deleteUser);
For finer control, use permission-based checks (a role has permissions like user:delete) {Mịn hơn thì dùng theo permission (role có permission như user:delete)}. And the most-missed rule {Và quy tắc hay sót nhất}: check ownership (IDOR), not just login {kiểm tra quyền sở hữu (IDOR), không chỉ đăng nhập}:
// ❌ Authenticated, but user A can pass user B's id and delete B's post
// ✅ Enforce ownership at the data layer
const post = await Post.findByPk(req.params.id);
if (!post) throw new NotFoundError();
if (post.userId !== req.user.id && req.user.role !== 'admin') throw new ForbiddenError();
IDOR (Insecure Direct Object Reference) is one of the most common real-world breaches — always scope queries by the current user, e.g.
where: { id, userId: req.user.id }{IDOR là một trong những rò rỉ phổ biến nhất thực tế — luôn giới hạn query theo user hiện tại}.
5.8 The OWASP defenses every Node API needs {Các phòng thủ OWASP mọi API Node cần}
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import cors from 'cors';
app.use(helmet()); // ~15 headers: CSP, HSTS, X-Frame-Options...
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true })); // never '*' WITH credentials
app.use('/api/', rateLimit({ windowMs: 15 * 60_000, limit: 100 }));
app.use('/auth/login', rateLimit({ windowMs: 15 * 60_000, limit: 10 })); // strict on login
app.use(express.json({ limit: '1mb' })); // body-size guard
The threats and their Node fixes {Các mối đe dọa và cách sửa trong Node}:
- XSS (injected scripts) → never
set:html/dangerouslySetInnerHTMLuntrusted data; set aContent-Security-Policy(helmet); keep tokens inHttpOnlycookies {không render dữ liệu chưa tin; đặt CSP; token trong cookie HttpOnly}. - CSRF (forged cross-site requests using your cookie) →
SameSitecookies + CSRF tokens (csrf-csrf) for state-changing routes {cookieSameSite+ token CSRF}. - SQL/NoSQL injection → parameterized queries / the ORM (Phase 4); validate input with Zod {query parameterized; validate bằng Zod}.
- SSRF (server tricked into calling internal URLs) → allowlist outbound hosts; block link-local/metadata IPs {allowlist host outbound; chặn IP nội bộ/metadata}.
- Brute force → strict rate limits + account lockout/backoff after repeated failures {rate limit chặt + khóa tài khoản/giãn thời gian}.
- Secrets → never hard-code; load from env (
node --env-file); keep.envgitignored; runnpm audit{không viết cứng; nạp từ env;.envgitignore;npm audit}.
import { z } from 'zod';
const Register = z.object({ email: z.string().email(), password: z.string().min(8) });
// validate at the edge → trust inward (Phase 3)
5.9 Senior security checklist {Checklist bảo mật senior}
- Passwords hashed with bcrypt(cost≥12)/argon2id; identical error+timing for unknown user vs wrong password {Hash mạnh; lỗi+thời gian giống nhau}.
- Tokens in
HttpOnly+Secure+SameSitecookies, neverlocalStorage{Token trong cookie an toàn}. - Short access token + rotating refresh token with reuse detection {Access ngắn + refresh xoay vòng có phát hiện tái dùng}.
- Authorization checks ownership at the data layer (no IDOR) {Phân quyền kiểm sở hữu ở tầng data}.
- helmet, CORS locked to origins (no
*+credentials), rate limits on auth routes {helmet, CORS khóa origin, rate limit}. - All input validated with Zod; queries parameterized; body-size limited {Input validate; query parameterized; giới hạn body}.
- Secrets in env,
.envgitignored,npm auditclean, generic errors to clients {Secret trong env; lỗi chung cho client}.
6. Hands-on projects {Dự án thực hành}
-
Complete password auth {Auth mật khẩu hoàn chỉnh}: register/login/logout with Passport local + bcrypt(12); confirm the stored value is a hash and that unknown-user and wrong-password return the same error {xác nhận giá trị lưu là hash và hai lỗi giống nhau}.
-
JWT access + refresh with rotation {JWT access + refresh có xoay vòng}: issue a 15m access token and a 7d refresh token in an
HttpOnlycookie; build/auth/refreshwith rotation + reuse-detection and a logout that revokes the family {phát access 15m + refresh 7d trong cookie HttpOnly; dựng/auth/refreshcó xoay vòng + phát hiện tái dùng và logout thu hồi}. -
Sessions in Redis {Session trong Redis}: switch the same app to
express-session+connect-redis; prove instant logout-everywhere by deleting the session {đổi sang session + Redis; chứng minh đăng xuất tức thì bằng cách xóa session}. -
OAuth2/OIDC login {Đăng nhập OAuth2/OIDC}: wire Google or GitHub with find-or-create and complete redirect→callback→issue-your-own-token {đấu Google/GitHub với find-or-create và hoàn tất luồng}.
-
RBAC + ownership {RBAC + sở hữu}: add roles +
authorize('admin'), modelRole→PermissionwithcheckPermission('user:delete'), and prove an IDOR attempt returns403{thêm role + permission; chứng minh IDOR trả403}. -
Harden the perimeter {Gia cố vành đai}: add helmet, locked CORS, login rate-limit (prove
429), Zod validation, and a CSRF token on a cookie-auth form {thêm helmet, CORS khóa, rate-limit (chứng minh429), Zod, và token CSRF}.
What’s next {Phần tiếp theo}
Your API is now safe to expose: correctly hashed passwords, secure cookies, a clear sessions-vs-JWT decision with refresh-token rotation, Passport strategies, OAuth2/OIDC login, RBAC + permissions with ownership checks, and the full OWASP hardening stack {API của bạn giờ an toàn để mở ra: mật khẩu hash đúng, cookie an toàn, quyết định session-vs-JWT rõ ràng kèm xoay vòng refresh, strategy Passport, đăng nhập OAuth2/OIDC, RBAC + permission kèm kiểm sở hữu, và bộ gia cố OWASP đầy đủ}.
In Phase 6, we level up code quality with advanced patterns & packages — dependency injection, the repository + service pattern, a Redis caching layer, background job queues with BullMQ, structured logging with pino/Winston, and schema validation strategy {Ở Phase 6, ta nâng chất lượng code với mẫu & package nâng cao — DI, repository + service, cache Redis, hàng đợi job nền với BullMQ, log có cấu trúc, và chiến lược validation}.