jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 / argon2hash + verify passwords (slow, salted) {hash mật khẩu}
jsonwebtokensign + verify JWTs {ký + xác minh JWT}
passport (+ strategies)pluggable auth middleware (local, JWT, OAuth) {middleware auth cắm được}
express-sessionserver-side sessions (cookie holds an id) {session phía server}
connect-redisstore sessions in Redis (multi-server) {lưu session trong Redis}
cookie-parserread/sign cookies {đọc/ký cookie}
helmetset ~15 security headers {đặt ~15 header bảo mật}
express-rate-limitthrottle abuse / brute force {kìm lạm dụng}
csrf-csrf / csrf-syncCSRF tokens for cookie auth {token CSRF}
zodvalidate + 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}.


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}
HttpOnlyXSS reading the token via document.cookie
Secureleaking over plain HTTP {rò qua HTTP thường}
SameSite=Lax/StrictCSRF (cross-site auto-sending) {CSRF}
__Host- name prefixcookie 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. An HttpOnly cookie is invisible to JavaScript {đừng bao giờ lưu token auth trong localStorage — XSS nào cũng đọc được. Cookie HttpOnly vô 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}:

  1. The payload is signed, not encrypted — anyone can Base64-decode and read it. Never put secrets (passwords, card numbers) in a JWT {payload được , không mã hóa — ai cũng decode đọc được. Đừng để bí mật trong JWT}.
  2. 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ý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 lookupjust verify signature
Multi-server {Đa server}needs shared store✅ none needed
Best for {Hợp cho}web appsAPIs, 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êm id_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 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/dangerouslySetInnerHTML untrusted data; set a Content-Security-Policy (helmet); keep tokens in HttpOnly cookies {không render dữ liệu chưa tin; đặt CSP; token trong cookie HttpOnly}.
  • CSRF (forged cross-site requests using your cookie) → SameSite cookies + CSRF tokens (csrf-csrf) for state-changing routes {cookie SameSite + 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 .env gitignored; run npm audit {không viết cứng; nạp từ env; .env gitignore; 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+SameSite cookies, never localStorage {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, .env gitignored, npm audit clean, generic errors to clients {Secret trong env; lỗi chung cho client}.

6. Hands-on projects {Dự án thực hành}

  1. 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}.

  2. JWT access + refresh with rotation {JWT access + refresh có xoay vòng}: issue a 15m access token and a 7d refresh token in an HttpOnly cookie; build /auth/refresh with rotation + reuse-detection and a logout that revokes the family {phát access 15m + refresh 7d trong cookie HttpOnly; dựng /auth/refresh có xoay vòng + phát hiện tái dùng và logout thu hồi}.

  3. 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}.

  4. 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}.

  5. RBAC + ownership {RBAC + sở hữu}: add roles + authorize('admin'), model RolePermission with checkPermission('user:delete'), and prove an IDOR attempt returns 403 {thêm role + permission; chứng minh IDOR trả 403}.

  6. 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 minh 429), 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}.