| name | authentication |
| description | Auth patterns: password hashing, JWT, sessions, and OAuth. Trigger: When implementing login, registration, token handling, or OAuth flows. |
| license | Apache 2.0 |
| metadata | {"version":"1.0","type":"domain"} |
Authentication
Patterns for implementing authentication correctly: password hashing strategy, token design, session management, OAuth flows, and security hardening. Language-agnostic principles; JWT/OAuth examples use Node.js.
When to Use
- Implementing login, registration, or logout
- Designing JWT or session-based auth
- Integrating OAuth / OIDC providers (Google, GitHub, etc.)
- Hardening an existing auth layer
Don't use for:
- Authorization / RBAC (permissions after authentication)
- Specific framework setup (use express, nest, nextjs)
Critical Patterns
โ
REQUIRED [CRITICAL]: JWT โ Sign, Verify, Never Just Decode
jwt.decode() skips signature verification. Always use jwt.verify() with the secret.
const payload = jwt.decode(token);
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
});
โ
REQUIRED [CRITICAL]: Short Access Token + Refresh Token Pattern
Access tokens expire fast (15 min). Refresh tokens are long-lived, stored httpOnly, rotated on use.
Access token: 15 min TTL ยท stored in memory (not localStorage) ยท sent as Bearer header
Refresh token: 7โ30 day TTL ยท httpOnly cookie ยท rotated on every refresh
localStorage.setItem('token', accessToken);
res.cookie('refreshToken', token, { httpOnly: true, secure: true, sameSite: 'strict' });
โ NEVER: Secrets in JWT Payload
JWTs are base64-encoded, not encrypted. Anyone can decode the payload.
const token = jwt.sign({ userId, password, creditCard }, secret);
const token = jwt.sign({ sub: userId, role: user.role }, secret, { expiresIn: '15m' });
โ
REQUIRED [CRITICAL]: Password Hashing โ Algorithm and Parameters
Passwords require a slow, memory-hard hash with an automatic per-password salt. Never use SHA-256, MD5, or any fast hash โ a leaked DB is cracked in hours.
| Algorithm | Verdict | Notes |
|---|
| Argon2id | โ
First choice | Memory-hard ยท no input limit ยท configurable cost |
| bcrypt | โ
Acceptable | Widely supported ยท cost โฅ 12 ยท 72-byte input limit |
| scrypt | โ
Acceptable | Memory-hard ยท built into many standard libraries |
| SHA-256 / MD5 | โ Never | Designed for speed โ wrong tool for passwords |
Core rules:
- Let the library generate the salt โ never manually
- Store the full PHC string output, not raw bytes
- Use library
verify/compare โ timing-safe; === is not
- Hash only the password field โ never concatenate other fields
- Always run the hash even when user not found (prevents email enumeration via timing)
Full detail โ algorithm parameters, pepper, NIST password policy, known antipatterns (Okta 2022), migration patterns: see references/password-hashing.md
โ
REQUIRED: Timing-Safe Comparison for Secrets
String equality (===) leaks timing information. Use crypto.timingSafeEqual.
if (providedToken === storedToken) { }
import { timingSafeEqual, createHash } from 'crypto';
const a = createHash('sha256').update(providedToken).digest();
const b = createHash('sha256').update(storedToken).digest();
if (timingSafeEqual(a, b)) { }
โ
REQUIRED: OAuth โ Validate State Parameter
The state param prevents CSRF in OAuth flows. Always generate, store, and verify it.
res.redirect(`https://provider.com/oauth?client_id=...&redirect_uri=...`);
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
res.redirect(`https://provider.com/oauth?state=${state}&...`);
if (req.query.state !== req.session.oauthState) throw new Error('CSRF detected');
โ
REQUIRED: Rate Limit Auth Endpoints
Login, register, and password reset are brute-force targets. Apply per-IP and per-account limits.
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
app.use('/auth', authLimiter);
โ NEVER: Expose Auth Errors in Detail
Don't confirm whether an email exists โ it enables user enumeration.
if (!user) return res.status(404).json({ error: 'Email not found' });
return res.status(401).json({ error: 'Invalid credentials' });
Decision Tree
Hashing a password?
โ New project โ Argon2id (memoryCost: 65536, timeCost: 3)
โ Existing project with bcrypt โ keep bcrypt, cost factor โฅ 12
โ No extra dependency wanted โ scrypt via standard library
โ Never MD5 / SHA-1 / SHA-256 / plain SHA-512 for passwords
Password using bcrypt and may be long (passphrases)?
โ Preferred: migrate to Argon2id โ no input limit, no workaround needed
โ If staying on bcrypt: pre-hash with SHA-256 and encode as hex (64 chars, safe)
before passing to bcrypt โ never pass raw bytes (null bytes truncate early)
Legacy hashes in DB (MD5 / SHA-1)?
โ Upgrade on next login: re-hash plaintext with Argon2id on successful auth
โ Store hashVersion to track which users have been migrated
โ Do not force mass reset
Setting password length policy?
โ Minimum 8 chars ยท Maximum โฅ 64 chars ยท No complexity rules (NIST SP 800-63B)
โ Check against HaveIBeenPwned on registration and password change
Adding defense-in-depth beyond salt?
โ Pepper: server-side secret in env config, applied before hashing, never in DB
Implementing login?
โ Hash comparison: use library verify/compare (timing-safe) โ never ===
โ Return same error for wrong email or wrong password (no enumeration)
โ Issue short JWT (15 min) + rotate refresh token into httpOnly cookie
โ OAuth: generate state param ยท validate on callback ยท exchange code for token
Storing tokens client-side?
โ Access token: memory only (not localStorage, not sessionStorage)
โ Refresh token: httpOnly secure cookie
JWT expiry?
โ Access token: 15 min max
โ Refresh token: 7โ30 days ยท rotate on use ยท invalidate on logout
Comparing tokens or secrets?
โ crypto.timingSafeEqual โ never ===
Auth endpoint (login/register/reset)?
โ Apply rate limiting (10 req / 15 min per IP)
โ Return generic error message (never confirm email existence)
OAuth integration?
โ Generate state param โ store server-side โ verify on callback
โ Use PKCE for public clients (SPAs, mobile)
Password reset?
โ Time-limited token (15โ60 min) ยท single-use ยท invalidate on use
โ Send via email only ยท never return in API response
Example
JWT auth with refresh token rotation.
async function login(email: string, password: string, res: Response) {
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedError('Invalid credentials');
}
const accessToken = jwt.sign({ sub: user.id, role: user.role }, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = crypto.randomBytes(32).toString('hex');
await db.refreshToken.create({ data: { token: refreshToken, userId: user.id } });
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, : });
{ accessToken };
}
() {
{ refreshToken } = req.;
stored = db..({ : { : refreshToken } });
(!stored) ();
db..({ : { : refreshToken } });
newRefresh = crypto.().();
db..({ : { : newRefresh, : stored. } });
accessToken = jwt.({ : stored. }, , { : });
res.(, newRefresh, { : , : , : });
{ accessToken };
}
Edge Cases
Refresh token theft: If a stolen refresh token is used after rotation, the original is already deleted โ the second use will fail. Optionally: invalidate all sessions for that user on double-use detection.
Stateless vs stateful JWTs: Stateless JWTs cannot be revoked mid-lifetime. If you need instant revocation (logout from all devices), store a token version or jti in the DB and check on each request.
PKCE for SPAs: SPAs cannot keep a client secret. Use OAuth PKCE flow (code_verifier + code_challenge) instead of client_secret.
Multi-device sessions: Store refresh tokens per device with a device identifier. Logout invalidates only that device's token; "logout everywhere" purges all.
Resources
- password-hashing.md - Algorithm selection, parameters, pepper, NIST policy, antipatterns, migration