Authentication vs Authorization
These two are constantly confused but solve different problems. Authentication (authN) answers "who are you?" — verifying identity via credentials. Authorization (authZ) answers "what are you allowed to do?" — deciding whether an already-identified principal may perform an action. AuthN always precedes authZ.
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | Who are you? | What can you do? |
| Mechanism | Password, MFA, OAuth login | Roles, permissions, policies |
| Result | A verified identity/session | Allow or deny a specific action |
| HTTP status | 401 Unauthorized | 403 Forbidden |
Password Hashing
Never store passwords in plaintext, and never "encrypt" them (encryption is reversible). Store a slow, salted one-way hash. When a user logs in, hash their input and compare. A per-user random salt defeats rainbow tables and ensures two identical passwords produce different hashes. Modern algorithms are deliberately slow and memory-hard to resist GPU/ASIC cracking.
| Algorithm | Notes |
|---|---|
| Argon2id | 2026 recommended default — memory-hard, tunable memory/time/parallelism |
| bcrypt | Battle-tested, widely available; cap input at 72 bytes |
| scrypt | Memory-hard alternative to Argon2 |
| MD5 / SHA-1 / SHA-256 | NEVER for passwords — too fast, no built-in salt |
import argon2 from 'argon2';
// Register — salt is generated and embedded in the hash string automatically
const hash = await argon2.hash(plainPassword, { type: argon2.argon2id });
// store `hash` in the DB (it also encodes params + salt)
// Login — constant-time verification, no manual salt handling
const ok = await argon2.verify(storedHash, submittedPassword);
if (!ok) throw new Error('invalid credentials');
Peppering & timing
Compare hashes with a constant-time function (the library's verify does this) to avoid timing attacks. Return the same generic "invalid credentials" error whether the email or the password was wrong, so attackers cannot enumerate accounts. An optional application-wide secret "pepper" (kept outside the DB) adds defence in depth.
Sessions & Cookies
In stateful session auth, the server creates a session record on login and hands the client an opaque session ID stored in a cookie. Every subsequent request sends the cookie; the server looks up the session. Because the ID is meaningless on its own, revoking a session is trivial — just delete the server-side record.
The security of cookie-based auth lives in the cookie flags:
| Flag | Purpose |
|---|---|
HttpOnly | JS cannot read the cookie — blocks XSS token theft |
Secure | Sent only over HTTPS |
SameSite | Lax/Strict limit cross-site sending — mitigates CSRF |
__Host- prefix | Forces Secure, path=/, no Domain — hardens scoping |
Max-Age | Expiry; short-lived sessions reduce hijack window |
res.cookie('sid', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax', // 'strict' for high-value apps
maxAge: 1000 * 60 * 60 * 8, // 8 hours
path: '/',
});
// Regenerate the session ID on privilege change (login) to prevent fixation
req.session.regenerate(() => { req.session.userId = user.id; });
JSON Web Tokens (JWT)
A JWT is a self-contained, signed token in the form header.payload.signature — three base64url segments joined by dots. Because the claims travel inside the token, the server can verify it statelessly without a database lookup, which is why JWTs suit distributed systems and APIs.
// Header { "alg": "HS256", "typ": "JWT" }
// Payload { "sub": "user_42", "role": "admin", "iat": 1717200000, "exp": 1717200900 }
// Signature = HMACSHA256(base64url(header) + "." + base64url(payload), secret)
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIn0.SflKx...
import jwt from 'jsonwebtoken';
const access = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
// Verify — throws on tampering or expiry
const claims = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // pin the algorithm — never trust the header's alg
});
The payload is not secret
Base64 is encoding, not encryption — anyone can decode a JWT and read its claims. The signature only guarantees integrity (it hasn't been altered), not confidentiality. Never put secrets in the payload. Always pin the accepted algorithms on verify to defeat the classic alg: none and RS256→HS256 confusion attacks.
Access vs Refresh Tokens
A stateless JWT cannot be revoked before it expires, so you keep access tokens short-lived (5–15 min). A longer-lived refresh token is exchanged for new access tokens. Refresh tokens should be opaque, stored server-side (so they can be revoked), rotated on each use, and delivered in an HttpOnly cookie.
Where to Store Tokens
| Storage | Tradeoff |
|---|---|
| HttpOnly cookie | Safe from XSS reads; needs CSRF protection (SameSite + token) |
| localStorage | Convenient but fully exposed to any XSS — avoid for tokens |
| In-memory (JS) | Not persisted across reloads; smallest XSS window |
Sessions vs JWT
| Aspect | Server Sessions | JWT (stateless) |
|---|---|---|
| State | Stored server-side (DB/Redis) | Self-contained in the token |
| Revocation | Instant — delete the record | Hard — must wait for expiry or keep a blocklist |
| Scaling | Needs shared session store | No lookup — scales horizontally |
| Payload size | Tiny opaque ID | Larger; sent on every request |
| Best for | Classic web apps, need instant logout | APIs, microservices, mobile |
OAuth 2.0 & OpenID Connect
OAuth 2.0 is a delegated authorization framework — it lets an app act on a user's behalf against a resource server without seeing their password ("Sign in with Google"). OpenID Connect (OIDC) is a thin identity layer on top that adds an id_token (a JWT describing who the user is). Use OAuth for access, OIDC for authentication.
Authorization Code Flow + PKCE
The Authorization Code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for all clients in 2026 — SPAs, mobile, and server apps alike. PKCE binds the authorization request to the token exchange so an intercepted code is useless without the original secret.
// 1. Client makes a random verifier and its SHA-256 challenge
const verifier = base64url(randomBytes(32));
const challenge = base64url(sha256(verifier));
// 2. Redirect user to the authorization server
// /authorize?response_type=code&client_id=APP&redirect_uri=...
// &scope=openid%20profile&state=xyz
// &code_challenge=CHALLENGE&code_challenge_method=S256
// 3. User authenticates & consents -> server redirects back with ?code=...&state=xyz
// (verify `state` matches to block CSRF on the callback)
// 4. Exchange the code for tokens, proving possession of the verifier
POST /token
grant_type=authorization_code&code=AUTH_CODE
&redirect_uri=...&client_id=APP&code_verifier=VERIFIER
// 5. Response: { access_token, refresh_token, id_token (OIDC), expires_in }
Other grant types exist — Client Credentials for machine-to-machine (no user), and Refresh Token for renewal. The legacy Implicit and Password grants are deprecated and should not be used.
Authorization: RBAC
Role-Based Access Control assigns permissions to roles and roles to users, so authorization checks reduce to "does this user's role grant this permission?" It is simpler than per-user ACLs and scales well. For finer-grained needs, ABAC (attribute-based) evaluates policies over user, resource, and context attributes.
const PERMISSIONS = {
admin: ['post:read', 'post:write', 'post:delete', 'user:manage'],
editor: ['post:read', 'post:write'],
viewer: ['post:read'],
};
function requirePermission(perm) {
return (req, res, next) => {
const role = req.user?.role;
if (!role) return res.status(401).end(); // not authenticated
if (!PERMISSIONS[role]?.includes(perm))
return res.status(403).end(); // authenticated, not allowed
next();
};
}
app.delete('/posts/:id', requirePermission('post:delete'), handler);
Enforce authZ server-side
Never rely on hiding a button in the UI. Every protected action must be checked on the server, and checks must include ownership — a user with post:write should only edit their own posts unless they are an admin. Skipping this is the root of IDOR and broken-access-control vulnerabilities.
Multi-Factor Authentication (MFA/TOTP)
MFA combines factors from different categories: something you know (password), something you have (phone, hardware key), and something you are (biometric). The most common software second factor is TOTP (RFC 6238) — an authenticator app derives a 6-digit code from a shared secret and the current 30-second time window.
import { authenticator } from 'otplib';
// Enrollment: generate a secret, show it as a QR (otpauth:// URI)
const secret = authenticator.generateSecret();
const uri = authenticator.keyuri(user.email, 'MyApp', secret);
// store `secret` encrypted, plus one-time recovery codes
// Login step 2: verify the code (allows +/- 1 window for clock drift)
const isValid = authenticator.verify({ token: submittedCode, secret });
Prefer phishing-resistant factors where possible — WebAuthn/passkeys (FIDO2) bind the credential to the origin and cannot be replayed on a phishing site, unlike SMS or even TOTP.
Common Attacks & Mitigations
| Attack | What happens | Mitigation |
|---|---|---|
| CSRF | Attacker site triggers a state-changing request using the victim's cookie | SameSite cookies + anti-CSRF token; check Origin |
| XSS | Injected JS steals tokens or acts as the user | Output encoding, CSP, HttpOnly cookies |
| Session fixation | Attacker sets a known session ID before login | Regenerate the session ID on login |
| Credential stuffing | Reused leaked passwords sprayed at login | Rate limiting, MFA, breached-password checks |
| Brute force | Automated password guessing | Slow hashing, throttling, account lockout/backoff |
| Token theft | Stolen JWT/refresh token replayed | Short expiry, refresh rotation, TLS, revocation list |
Practice Exercises
- Implement register/login with Argon2id, returning identical errors for unknown-email and wrong-password cases.
- Build a session-cookie login with correct
HttpOnly/Secure/SameSiteflags and session-ID regeneration on login. - Issue a 15-minute access JWT plus a rotating refresh token stored server-side; add a refresh endpoint.
- Add algorithm pinning to your JWT verification and write a test proving an
alg: nonetoken is rejected. - Implement the OAuth Authorization Code + PKCE flow against a provider, verifying the
stateparameter on the callback. - Add TOTP-based MFA with QR enrollment and one-time recovery codes.
- Write an RBAC middleware and add an ownership check so users can only edit their own resources.