
Security Best Practice
- 17 installs
- 39 repo stars
- Updated July 28, 2026
- himself65/auth-spec
Audit and harden authentication code against OWASP/NIST best practices across credential storage, sessions, MFA, OAuth, rate limiting, CSRF, and security headers.
About
Audits and hardens auth code against 2024-2026 security rules organized by impact, then applies fixes and produces a PASS/FAIL/MISSING report. A developer uses it to check an auth implementation for vulnerabilities and apply OWASP-recommended patterns.
- Rule files cover credential storage, session security, OAuth/OIDC, MFA/passkeys, CSRF, and HTTP headers
- Report-only CSP and cautious HSTS guidance to avoid breaking production
Security Best Practice by the numbers
- 17 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,604 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/himself65/auth-spec --skill security-best-practiceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 39 |
| Last updated | July 28, 2026 |
| Repository | himself65/auth-spec ↗ |
What it does
Audit and harden authentication code against OWASP/NIST best practices across credential storage, sessions, MFA, OAuth, rate limiting, CSRF, and security headers.
Files
Security Best Practice
You are auditing and hardening authentication code against modern (2024-2026) security best practices.
Rules
Individual security rules are in the rules/ directory, organized by impact priority. Read rules/_sections.md for the full taxonomy, and read individual rule files for checklists and fix patterns.
Critical Impact:
rules/credential-storage.md— Password hashing (argon2id first), HIBP breach-check, pepper, secret managementrules/error-handling.md— User enumeration, timing attacks, status/size symmetry, stack trace leaks
High Impact:
rules/session-security.md— Token generation,__Host-/Partitionedcookies, JWT pitfalls, session fixation, rotation on state changerules/input-validation.md— SQL/NoSQL injection, XSS, SSRF, open redirect, schema validationrules/oauth-oidc.md— Code + PKCE,state/nonce, redirect-URI allow-list, account-linking pre-takeoverrules/mfa-passkeys.md— TOTP replay prevention, WebAuthn verification, step-up, recovery codesrules/token-lifecycle.md— Password reset, email verification, magic link, OTP hashing & one-time use
Medium Impact:
rules/rate-limiting.md— Multi-dim throttling (account + IP), SMS pumping, credential stuffingrules/csrf-protection.md— Origin / Fetch-Metadata, double-submit,SameSitecaveats, CORS pitfalls
Lower Priority:
rules/http-security-headers.md— HSTS, nonce-CSP, COOP/COEP/CORP,Clear-Site-Data, Trusted Types
Step 1: Detect Project Context
Before starting, scan the user's project to understand their stack:
1. Framework config files (next.config.*, package.json, go.mod, Cargo.toml, pyproject.toml, build.gradle*, pom.xml) 2. Existing auth code — route handlers for sign-up, sign-in, session, sign-out, password reset, email verification 3. Database/ORM setup and session storage (DB table, Redis, JWT) 4. Existing security measures (rate limiting, CSRF tokens, CSP headers, MFA, OAuth providers) 5. Whether the app uses cookies, Bearer tokens, or both (determines which rules apply)
Step 2: Choose Audit Scope
Use the AskUserQuestion tool to ask the user what they want to harden. Use multiSelect: true so they can pick multiple areas at once.
Ask "Which security areas do you want to audit and harden?" with header "Security audit scope".
- Credential storage — "Password hashing (argon2id/bcrypt), breach check, pepper, secret management"
- Session security — "Token generation, cookie flags, expiry, fixation, JWT pitfalls"
- Input validation — "SQL/NoSQL injection, XSS, SSRF, open redirect, schema validation"
- OAuth / OIDC — "Code + PKCE, state/nonce, redirect URIs, account linking pre-takeover"
- MFA & passkeys — "TOTP, WebAuthn, recovery codes, step-up flows"
- Short-lived tokens — "Password reset, email verification, magic link, OTP"
- Rate limiting & brute-force protection — "Multi-dim throttling, SMS pumping, credential stuffing"
- CSRF protection — "Cross-site request forgery for cookie-based auth"
- HTTP security headers — "CSP, HSTS, COOP/COEP, Clear-Site-Data"
- Error handling & information leakage — "Generic errors, no timing/size leaks, no user enumeration"
- Full audit (all of the above) — "Comprehensive security review"
Step 3: Run the Audit
For each selected area, read the corresponding rule file from rules/ and review the user's code against its checklist. Report findings as:
- PASS — implementation is correct
- FAIL — vulnerability or misconfiguration found (include file path, line number, and fix)
- MISSING — security control is absent (include where to add it and sample code)
After the audit, apply fixes directly to the code. For each fix, explain what was wrong and why the fix is necessary. Link to the relevant rule file for reference.
Step 4: Generate Report
After applying fixes, produce a summary table:
| Area | Status | Issues Found | Fixed |
|---|---|---|---|
| Credential storage | PASS/FAIL | description | Yes/No |
| Session security | PASS/FAIL | description | Yes/No |
| OAuth / OIDC | PASS/FAIL | description | Yes/No |
| MFA & passkeys | PASS/FAIL | description | Yes/No |
| Token lifecycle | PASS/FAIL | description | Yes/No |
| ... | ... | ... | ... |
For any issues that cannot be auto-fixed (e.g., require infrastructure changes like adding Redis for rate limiting, configuring HSTS preload at the edge, registering OAuth redirect URIs with the IdP), list them as manual action items with clear instructions.
Step 5: Verify Fixes
After applying fixes: 1. If the project has tests, run them to ensure nothing broke 2. If the project has a linter, run it to verify code style 3. If the project has a build step, run it to check for compilation errors 4. If feasible, exercise the auth flows end-to-end (sign-up, sign-in, reset, logout) to confirm behavior
Implementation Rules
- Never weaken existing security. If the code already uses argon2id, do not downgrade to bcrypt. If cookies already have
SameSite=Strictand the__Host-prefix, do not loosen them. - Follow the project's existing patterns. If they use middleware for other concerns, add security middleware the same way. Match naming conventions, file structure, and error handling style.
- Do not add dependencies without asking. If a fix requires a new library (e.g.
argon2,helmet,@simplewebauthn/server,hibp), ask the user before adding it. - Adapt to the language/framework idioms. Use the canonical approach for each ecosystem (e.g.
helmetmiddleware in Express,SecurityFilterChainin Spring Boot, middleware in Go/Chi). - Be conservative with CSP. A too-strict
Content-Security-Policycan break the application. Start in report-only mode, collect violations for 1–2 weeks, then enforce. - Be conservative with HSTS preload. Removal takes weeks. Only preload once every subdomain serves HTTPS.
- Respect the auth-spec project rules. All auth code must be hand-written — no auth libraries (better-auth, next-auth, Auth.js, lucia, passport, etc.). Only allowed deps: web framework, ORM, password-hashing lib, WebAuthn verification lib, security-header/CSRF utility libs, and a rate-limit store client (e.g. Redis).
Sources
- OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- OWASP Password Storage Cheat Sheet
- OWASP Multifactor Authentication Cheat Sheet
- OWASP Forgot Password Cheat Sheet
- OWASP Credential Stuffing Prevention Cheat Sheet
- OWASP API Security Top 10 (2023)
- NIST SP 800-63B rev.4 — Digital Identity Guidelines
- OAuth 2.0 Security Best Current Practice
- OAuth 2.1 (draft)
- W3C WebAuthn Level 3
- RFC 8725 — JWT BCP
- MDN HTTP Security Headers
Sections
Defines the organization and priority of security best practice rules.
Critical Impact
1. Credential Storage (credential-) Password hashing, secret management, breached-password checks, key rotation.
2. Error Handling & Information Leakage (error-) User enumeration, timing attacks, response-size/status symmetry, stack-trace leaks.
High Impact
3. Session Security (session-) Token generation, cookie flags (__Host-, Partitioned), session lifecycle, fixation prevention, JWT pitfalls.
4. Input Validation (input-) SQL/NoSQL injection, XSS, header injection, SSRF, open redirect, request body schema.
5. OAuth 2.0 / OIDC (oauth-) Authorization Code + PKCE, state/nonce, redirect-URI allow-listing, account-linking pre-takeover defense.
6. MFA, TOTP, Passkeys / WebAuthn (mfa-) Factor enrollment, recovery codes, step-up, passkey verification, TOTP replay prevention.
7. Short-Lived Token Lifecycle (token-) Password reset, email verification / change, magic link, OTP — hashing, one-time use, sibling invalidation.
Medium Impact
8. Rate Limiting & Brute-Force Protection (rate-limiting-) Multi-dimensional throttling (account + IP), SMS pumping, credential stuffing, CAPTCHA placement.
9. CSRF Protection (csrf-) Origin / Fetch-Metadata checks, double-submit token, SameSite caveats, CORS pitfalls.
Lower Priority
10. HTTP Security Headers (http-headers-) HSTS, CSP (nonce), COOP/COEP/CORP, Clear-Site-Data on logout, Trusted Types.
Rule Title
Impact: LEVEL
Brief explanation of what this rule checks and why it matters.
Checklist
| Check | Requirement |
|---|---|
| Check name | What must be true |
Incorrect
// BAD: description of the problemCorrect
// GOOD: description of the fixReferences
- Link to relevant standard or documentation
Credential Storage
Impact: CRITICAL
Weak password hashing or leaked secrets are the most direct path to mass account compromise. Every auth system must get credential storage right.
Checklist
| Check | Requirement |
|---|---|
| Password hashing algorithm | Prefer argon2id (OWASP 2024 top choice). Acceptable: scrypt, bcrypt (cost ≥ 12). Never MD5, SHA-1, SHA-256 alone, PBKDF2-SHA1, or plaintext. |
| Argon2id parameters | Minimum m=19456 (19 MiB), t=2, p=1. OWASP 2024 recommends m=47104 (46 MiB), t=1, p=1 for higher security. |
| Bcrypt parameters | Cost factor ≥ 12 in 2026 (≥ 10 is the old baseline; increase over time). Pre-hash with SHA-256 + base64 if you accept passwords > 72 bytes to avoid the bcrypt 72-byte truncation. |
| Salt handling | Per-password random salt (≥ 16 bytes). Bcrypt/argon2/scrypt handle this — verify no custom salt reuse. |
| Pepper (optional, defense-in-depth) | Application-layer secret HMAC applied before hashing. Stored in env/KMS, not the database. Useful when the password column may leak but secrets won't. |
| Password in logs | Grep for console.log, print, log., logger. near password variables. Must never log passwords, hashes, or JWTs. |
| Password in responses | API responses must never include passwordHash, password, hash, or salt fields. Use a DTO / select allow-list. |
| Secrets in source code | No hardcoded JWT secrets, API keys, or database passwords. Use env vars, KMS, Vault, or a secret manager. Run git-secrets / trufflehog pre-commit. |
| Password minimum length | ≥ 8 characters server-side (NIST SP 800-63B rev.4). Prefer ≥ 12 for admin/privileged accounts. |
| Password max length | Enforce a hard upper bound (e.g. 128, or 64 for bcrypt-only when not pre-hashing) to prevent long-password DoS. |
| Breached password check | At sign-up and password change, reject passwords found in known breach corpora. Use the HIBP Pwned Passwords k-anonymity API (SHA-1 prefix) or an offline bloom filter — never send the full password. |
| Password complexity rules | NIST SP 800-63B rev.4 deprecates forced complexity/rotation. Do not require uppercase/symbols. Do not force periodic rotation — rotate only on suspected compromise. |
| Key rotation | Rotate JWT/HMAC secrets and DB encryption keys periodically. Support multiple active verifiers during rollover. Document the rotation process. |
Incorrect
// BAD: weak hashing
const hash = crypto.createHash('sha256').update(password).digest('hex');// BAD: sending full password to remote breach API
await fetch(`https://api.example.com/check?password=${password}`);# BAD: no max length — attacker can send 1MB password to DoS bcrypt/argon2
password = request.json["password"]Correct
// GOOD: argon2id with reasonable parameters
import argon2 from 'argon2';
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});// GOOD: bcrypt with pre-hash to avoid 72-byte truncation
import bcrypt from 'bcrypt';
import crypto from 'crypto';
const prehashed = crypto.createHash('sha256').update(password).digest('base64');
const hash = await bcrypt.hash(prehashed, 12);// GOOD: HIBP k-anonymity — only first 5 SHA-1 chars leave the server
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const breached = (await res.text()).split('\n').some(line => line.startsWith(suffix));
if (breached) throw new Error('This password appears in known breaches. Please choose another.');# GOOD: enforce bounds
password = request.json["password"]
if not (8 <= len(password) <= 128):
return jsonify(error="Password must be 8-128 characters"), 400References
CSRF Protection
Impact: MEDIUM
If the app uses cookie-based authentication, CSRF allows attackers to perform actions as the logged-in user by tricking the browser into sending authenticated requests. SameSite=Lax alone is not sufficient — top-level GETs can still carry cookies, and GET → state change is a common mistake.
Checklist
| Check | Requirement |
|---|---|
| Cookie-based auth | If session is in a cookie, CSRF protection is mandatory on every state-changing request. |
SameSite on session cookies | SameSite=Lax minimum; prefer Strict for the session cookie itself. Not a substitute for explicit CSRF defense — older browsers and some embedded contexts don't enforce it. |
| Idempotency of GET | GET/HEAD/OPTIONS must never change state. Enforce with routing — not with "we just don't do that". |
| CSRF token (primary defense) | For state-changing requests: synchronizer token or double-submit cookie. Token should be 128+ bits of entropy, bound to the session, and rotated on privilege change. |
| Origin/Referer (defense-in-depth) | On every unsafe-method request, require Origin (or Referer fallback) to equal an allow-list of your own origins. Reject with 403 otherwise. |
Sec-Fetch-Site / Fetch Metadata | Modern browsers send Sec-Fetch-Site. Reject state-changing requests when Sec-Fetch-Site is cross-site unless the route is a whitelisted webhook. |
| CORS ≠ CSRF protection | A permissive CORS config (Access-Control-Allow-Origin: * with credentials, or reflecting Origin) enables CSRF. Never reflect origin for authenticated endpoints; use an explicit allow-list. |
| Authorization header auth | Bearer tokens in Authorization header are not auto-attached by the browser, so CSRF is not required — but ensure no cookie-based fallback exists on the same endpoint. |
| Login CSRF | The login endpoint also needs CSRF protection (otherwise an attacker can log the victim into the attacker's account to harvest behavior). Use a pre-session cookie + token. |
| Logout CSRF | Rate-limit or CSRF-protect logout so an attacker can't force-logout users en masse. |
| Token in URL | Never put CSRF tokens (or any session tokens) in query strings or path — they leak to logs, analytics, Referer. |
Incorrect
// BAD: SameSite=Lax alone, no CSRF token, no Origin check
app.post('/api/change-email', (req, res) => {
const session = getSessionFromCookie(req);
updateEmail(session.userId, req.body.email);
});// BAD: CORS reflecting Origin for credentialed requests — enables CSRF from any site
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});Correct
// GOOD: layered defense — Origin check + double-submit CSRF token
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
function csrfProtection(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
// 1. Fetch Metadata / Origin allow-list
const origin = req.headers.origin || req.headers.referer;
if (!origin || !ALLOWED_ORIGINS.has(new URL(origin).origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
if (req.headers['sec-fetch-site'] === 'cross-site') {
return res.status(403).json({ error: 'Cross-site request blocked' });
}
// 2. Double-submit token (constant-time compare)
const cookieToken = req.cookies['__Host-csrf'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken ||
!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
next();
}// GOOD: CSRF cookie — __Host- prefix + SameSite=Strict
res.setHeader('Set-Cookie',
`__Host-csrf=${csrfToken}; HttpOnly=false; Secure; SameSite=Strict; Path=/`
);
// Note: CSRF cookie is intentionally NOT HttpOnly — JS reads it to echo in the X-CSRF-Token header.References
Error Handling & Information Leakage
Impact: CRITICAL
Verbose error messages and inconsistent response behavior leak whether accounts exist, what tech stack is used, and internal implementation details — all of which help attackers. The fix is not just the error body: attackers also observe status codes, response timing, and response size.
Checklist
| Check | Requirement |
|---|---|
| Generic auth errors | Sign-in failure must return generic "Invalid email or password" — never "User not found" or "Wrong password". |
| Status-code symmetry | "User not found" and "wrong password" must return the same HTTP status (both 401). Attackers distinguish 401 vs 404 just as easily as text. |
| Response-size symmetry | Success, "no such user", and "wrong password" branches should return similarly sized bodies. Differing sizes leak state even with identical text. |
| Sign-up enumeration | Sign-up with an existing email must return the same status/shape as success (and send a "someone tried to register with your email" email to the existing user). See create-auth skill for details. |
| Password reset enumeration | Always respond "If an account exists, we sent a reset email" — never confirm whether the email is registered. Enqueue the email async so timing doesn't leak. |
| Forgot-username / email-exists UX | The "check if email is available" UX at sign-up does leak enumeration. If you ship it, rate-limit it aggressively and CAPTCHA-gate it. |
| Stack traces | Production error responses must never include stack traces, SQL errors, internal paths, or library versions. Log server-side; return opaque error IDs to the client for support. |
| Timing attacks — password | Use constant-time verify (bcrypt/argon2 verify is constant-time). When the user doesn't exist, still hash a dummy password so the response time matches. |
| Timing attacks — tokens | Compare session tokens, CSRF tokens, API keys, email-verification tokens with crypto.timingSafeEqual / hmac.compare_digest. Never == / ===. |
| Timing attacks — external calls | Async email sending, DB lookups by email, and OAuth provider calls can all add branch-dependent latency. Wrap in constant-time wrappers or jitter where critical. |
| Side-channel via rate-limit responses | If your 429 appears only when the account exists, attackers enumerate via the rate-limit. Apply the limiter uniformly. |
| Verbose headers | Strip X-Powered-By, Server, framework-version headers. |
| Debug mode | Verify debug/dev mode is off in prod: Django DEBUG=False, Rails config.consider_all_requests_local=false, Express NODE_ENV=production, Next.js next build not next dev. |
| Source maps | Don't ship production JS source maps unless they're behind auth. They reveal original file paths and logic. |
| Error IDs for support | On 500s, return { error: "Internal server error", errorId: "<uuid>" } and log the UUID server-side. Lets support correlate without leaking internals. |
Incorrect
// BAD: leaks whether email exists (text + status + timing)
const user = await db.user.findUnique({ where: { email } });
if (!user) return res.status(404).json({ error: "User not found" });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "Wrong password" });# BAD: stack trace in response
except Exception as e:
return jsonify(error=str(e), trace=traceback.format_exc()), 500// BAD: token comparison not constant-time
if (providedToken === expectedToken) { /* ... */ }Correct
// GOOD: same status, same shape, constant-time-ish regardless of user existence
const DUMMY_HASH = '$argon2id$v=19$m=19456,t=2,p=1$...'; // pre-computed at boot
async function signIn(email, password) {
const user = await db.user.findUnique({ where: { email } });
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2.verify(hash, password);
if (!user || !ok) {
return res.status(401).json({ error: "Invalid email or password" });
}
// ...
}# GOOD: generic error, correlation ID, real details server-side
import uuid, logging
try:
...
except Exception:
error_id = str(uuid.uuid4())
logging.exception(f"auth error {error_id}")
return jsonify(error="Internal server error", errorId=error_id), 500// GOOD: constant-time token compare
import crypto from 'crypto';
const a = Buffer.from(providedToken);
const b = Buffer.from(expectedToken);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'Invalid token' });
}References
HTTP Security Headers
Impact: LOW (individually) / HIGH (cumulatively)
Security headers are a defense-in-depth layer. They don't prevent auth bugs directly, but they contain the blast radius of XSS, clickjacking, protocol downgrade, and cross-origin attacks. Auth routes deserve stricter headers than the rest of the site.
Checklist
| Check | Requirement |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload once you've verified full HTTPS coverage (preload is hard to reverse). |
Content-Security-Policy | At minimum: default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'. Prefer nonce- or hash- based script-src over 'unsafe-inline'. Use 'strict-dynamic' for modern apps. |
X-Content-Type-Options | nosniff — prevents MIME sniffing. |
X-Frame-Options / frame-ancestors | X-Frame-Options: DENY for legacy + frame-ancestors 'none' in CSP for modern browsers. Use SAMEORIGIN only if the app genuinely iframes itself. |
Referrer-Policy | strict-origin-when-cross-origin (general) or no-referrer (auth pages, reset links) — prevents leaking URLs with tokens. |
Permissions-Policy | Deny features the app doesn't use: camera=(), microphone=(), geolocation=(), interest-cohort=(). |
Cross-Origin-Opener-Policy | same-origin on authenticated pages — prevents cross-origin window references and enables Spectre-style isolation. Required for some modern APIs. |
Cross-Origin-Embedder-Policy | require-corp or credentialless — pairs with COOP for full origin isolation. Can break legacy embeds — verify before rolling out. |
Cross-Origin-Resource-Policy | same-origin on auth responses — prevents other origins from embedding them via <img>/<script> for side-channel leaks. |
Cache-Control on auth responses | no-store on sign-in, sign-up, token, reset, session, and user-profile responses. Prevents caching tokens or PII in browser/proxy caches. |
Clear-Site-Data on logout | Clear-Site-Data: "cookies", "storage", "cache" on the logout response — reliably wipes client state. Omit "executionContexts" unless you want to force reload. |
Trusted Types (optional, high security) | Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default — eliminates DOM XSS sinks. Requires app code changes. |
| Verbose server headers | Strip Server, X-Powered-By, framework-version headers — they help attackers fingerprint your stack. |
| HTTPS redirect | Plain HTTP must 301 → HTTPS at the edge. Don't rely on HSTS alone for the first visit. |
Correct
// Middleware to set security headers on every response
function securityHeaders(req, res, next) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'nonce-${res.locals.cspNonce}' 'strict-dynamic'`,
"style-src 'self' 'unsafe-inline'", // tighten later with hashes/nonces
"img-src 'self' data:",
"connect-src 'self'",
"upgrade-insecure-requests",
].join('; ')
);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), interest-cohort=()');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
// COEP is stricter — turn on only after auditing embeds
// res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.removeHeader('X-Powered-By');
next();
}
// On auth endpoints, add:
res.setHeader('Cache-Control', 'no-store');
// On logout:
res.setHeader('Clear-Site-Data', '"cookies", "storage", "cache"');Notes
- Deploy CSP in report-only mode first (
Content-Security-Policy-Report-Only) and collect violations for 1–2 weeks before enforcing. A too-strict CSP breaks the app. - Prefer nonce-based CSP over
'unsafe-inline'. The nonce should be a per-request 128-bit random value. - HSTS
preloadis a one-way trip — removal from the preload list takes weeks. Only preload once you're sure every subdomain serves HTTPS.
References
Input Validation
Impact: HIGH
Unvalidated input is the root cause of SQL injection, XSS, header injection, SSRF, and open redirect — all of which can lead to account takeover or full system compromise. Validate at the boundary, with an explicit schema, allow-list where possible.
Checklist
| Check | Requirement |
|---|---|
| SQL injection | All queries use parameterized queries or ORM methods. No string concatenation with user input, including ORDER BY, LIMIT, or table/column names — use allow-lists for those. |
| NoSQL injection | For Mongo et al., reject operator keys ($gt, $ne, $where, $regex) in user-supplied objects. Cast types (string email should not become a {$ne: null} query). |
| Schema validation | Parse every request body through a schema library (Zod / Valibot / Pydantic / validator / Joi). Reject unknown fields (.strict() / extra=forbid). |
| Email validation | Validate format, normalize (lowercase, strip + aliases if your policy says so), reject control chars and CRLF. Treat Unicode confusables carefully for display. |
| Email / header CRLF | Never pass user input directly into email headers (To, Subject, From) or HTTP response headers. CRLF injection splits headers and can cause email spoofing or HTTP response splitting. |
| XSS — output encoding | Encode on output, not input. Use the framework's auto-escaping (React JSX, Jinja2 autoescape, Go html/template). Never set dangerouslySetInnerHTML / innerHTML with user input. |
| XSS — sanitize rich content | If you accept HTML (rich editor), run it through DOMPurify / bleach / sanitize-html on the server with an allow-list. Don't trust client-side sanitization. |
| XSS — CSP fallback | Even with perfect escaping, ship a nonce-based CSP (script-src 'self' 'nonce-...'). See http-security-headers.md. |
| Open redirect | Every redirect target must be validated against an allow-list (either a fixed list of paths, or your own origin). Common attack surfaces: ?redirect=, ?next=, OAuth callbackUrl, post-signin return URLs. Reject //evil.com, \evil.com, javascript:, data:, and URLs whose origin ≠ yours. |
| SSRF | If auth code fetches URLs (avatar fetch, SSO metadata, webhook), block requests to private IP ranges (RFC1918, link-local 169.254/16, loopback, IPv6 ULA, metadata IPs 169.254.169.254). Resolve DNS yourself and check the resolved IP — don't trust the hostname. |
| Path traversal | If auth involves file operations (avatar upload, export), reject .., \, NUL bytes, and absolute paths. Resolve and verify the final path is inside the intended root. |
| JSON parsing | Catch JSON parse errors → 400. Enforce request-body size limits (e.g. 1 MB for auth endpoints) to prevent memory-DoS. |
| Content-Type enforcement | Reject requests with unexpected Content-Type on auth endpoints. A text/plain POST can bypass some CSRF defenses. |
| Unicode normalization | Normalize usernames/emails (NFKC) before storage and comparison to prevent homoglyph account duplicates. |
| Prototype pollution | In Node.js, don't Object.assign(target, userInput). Use null-prototype objects, structuredClone, or a parser that strips __proto__ / constructor.prototype. |
| File uploads (avatars) | Validate magic bytes, not just extension. Strip EXIF. Serve from a separate cookie-less domain or with Content-Disposition: attachment + X-Content-Type-Options: nosniff. |
Incorrect
// BAD: SQL injection
const user = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// BAD: NoSQL injection (no type check)
const user = await User.findOne({ email: req.body.email }); // {$ne: null} bypass
// BAD: open redirect
res.redirect(req.query.next);
// BAD: SSRF
const avatar = await fetch(req.body.avatarUrl);
// BAD: XSS via innerHTML
document.getElementById('welcome').innerHTML = `Welcome, ${user.name}`;Correct
// GOOD: parameterized query + Zod schema + normalized email
import { z } from 'zod';
const SignInBody = z.object({
email: z.string().email().max(254).toLowerCase(),
password: z.string().min(8).max(128),
}).strict();
const { email, password } = SignInBody.parse(req.body);
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);// GOOD: redirect allow-list (same-origin paths only)
const SAFE_NEXT = /^\/[a-zA-Z0-9/_-]*$/; // relative paths, no protocol-relative
const next = typeof req.query.next === 'string' && SAFE_NEXT.test(req.query.next)
? req.query.next
: '/';
res.redirect(next);// GOOD: SSRF — resolve + block private ranges
import dns from 'node:dns/promises';
import net from 'node:net';
async function safeFetch(url: string) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('bad scheme');
const { address } = await dns.lookup(parsed.hostname);
if (isPrivateIp(address)) throw new Error('blocked IP');
return fetch(`${parsed.protocol}//${address}${parsed.pathname}`, {
headers: { Host: parsed.hostname },
redirect: 'manual', // follow manually so each hop is re-validated
});
}References
MFA, TOTP, Passkeys / WebAuthn
Impact: HIGH
MFA closes the "stolen password" attack class — but only if enrollment, recovery, and step-up flows don't themselves become bypasses. Passkeys / WebAuthn are the strongest modern option and should be the default for new systems.
Checklist — General MFA
| Check | Requirement |
|---|---|
| Prefer phishing-resistant factors | Passkeys (WebAuthn) and hardware keys (FIDO2) are phishing-resistant. TOTP and push notifications are not — they can be relayed in real time. SMS is the weakest. |
| MFA not bypassable by alternate flows | Ensure every sign-in path enforces MFA once enabled: password login, magic link, "forgot password" reset completion, OAuth account linking, API token issuance. A common bug: password reset sets a new session without MFA. |
| Step-up for sensitive actions | Re-verify MFA before: password/email/phone change, MFA factor add/remove, session revocation of others, payment changes, privileged API key creation. |
| Recovery codes | Generate 8–10 one-time codes (≥ 64 bits entropy each) at MFA enrollment. Show once. Store hashed server-side. Invalidate after use. |
| Account recovery ≠ "contact support" as a bypass | Document recovery: identity verification + cooling-off period. Never let support bypass MFA without verification + audit log. Advertise the delay publicly so attackers know it's not exploitable. |
| MFA enrollment requires re-auth | Require password / current-session-in-good-standing to add or remove a factor. Send a confirmation email. |
| Rate-limit verification | See rate-limiting.md — MFA verification must be throttled per account and per IP. Lock the factor after ~5 failed codes. |
| Audit log | Log MFA enroll/remove, factor use, recovery-code use, "sign out all other sessions", and email them to the user. |
Checklist — TOTP (RFC 6238)
| Check | Requirement |
|---|---|
| Secret length | ≥ 160 bits (20 bytes) of entropy, base32-encoded. |
| Algorithm | SHA-1 is still the RFC default for compatibility; SHA-256 is fine if your authenticator app supports it. |
| Time window | Accept ±1 time step (30s default) to tolerate clock drift. No more — wider windows weaken security. |
| Replay prevention | Track the last successfully used counter per user and reject codes ≤ that counter. Without this, an attacker who sees one code can reuse it within its window. |
| Secret storage | Encrypted at rest. Never returned to the client after enrollment. |
| Enrollment confirmation | Require the user to enter a code from the QR before activating TOTP — otherwise users lock themselves out. |
Checklist — WebAuthn / Passkeys
| Check | Requirement |
|---|---|
| Relying Party ID | rpId must be your registrable domain (e.g. example.com, not auth.example.com). Never set it to a public suffix. |
origin check | On verification, the returned clientDataJSON.origin must be in your allow-list of exact origins. No wildcards. |
| Challenge | Server-generated random challenge (≥ 128 bits). One-time use. Bound to the session / short TTL (≤ 5 min). |
| User verification (UV) | Require userVerification: "required" for passkey-only login; preferred if combined with password. Check flags.uv on the authenticator data. |
| Attestation | attestation: "none" is fine for consumer apps. Use "direct" + attestation verification only if you need to restrict to specific authenticator vendors. |
| Credential ID storage | Store the credential's ID, public key, sign counter, transports, and backup-state. Key on (userId, credentialId). |
| Sign counter | If the authenticator reports a counter, reject on counter regression (indicates cloning). Resident-key / synced passkeys often report 0 — don't treat 0 as regression. |
| Multiple passkeys per user | Allow users to register ≥ 2 passkeys (primary + backup). A single-authenticator lockout is a common support issue. |
| Conditional UI / autofill | Use mediation: "conditional" for the nicer passkey UX, but never depend on the UI for security — always re-verify server-side. |
| Discoverable credentials (resident keys) | Prefer residentKey: "required" for passkey sign-in without a username step. |
| Passkey + password downgrade | Once a user has passkeys, consider disabling password sign-in or gating it behind an extra factor. Otherwise the weakest factor still sets the security level. |
Incorrect
// BAD: TOTP — no replay tracking, too-wide window
function verifyTotp(secret, code) {
for (let w = -5; w <= 5; w++) { // 10 steps!
if (totp(secret, nowStep() + w) === code) return true;
}
return false;
}// BAD: password reset skips MFA
async function confirmReset(token, newPassword) {
const userId = await consumeResetToken(token);
await setPassword(userId, newPassword);
await createSession(userId); // no MFA step — takeover complete
}Correct
// GOOD: TOTP with replay protection and tight window
async function verifyTotp(user, code) {
for (const step of [nowStep() - 1, nowStep(), nowStep() + 1]) {
if (step <= user.totpLastUsedStep) continue; // replay
if (constantTimeEq(totp(user.totpSecret, step), code)) {
await db.user.update({ where: { id: user.id }, data: { totpLastUsedStep: step } });
return true;
}
}
return false;
}
// GOOD: password reset requires MFA before new session
async function confirmReset(token, newPassword) {
const userId = await consumeResetToken(token);
await setPassword(userId, newPassword);
await revokeAllSessions(userId);
if (await userHasMfa(userId)) {
return { status: 'mfa_required', pendingId: issuePendingMfaSession(userId) };
}
return createSession(userId);
}References
OAuth 2.0 / OIDC Security
Impact: HIGH
OAuth bugs are the most common source of modern account takeover. The protocol is flexible; the specific choices you make decide whether "sign in with Google/GitHub" becomes a universal takeover primitive.
Checklist
| Check | Requirement |
|---|---|
| Authorization Code + PKCE | Always use the Authorization Code flow. Always include PKCE (code_challenge, S256) even for confidential (server-side) clients — OAuth 2.1 and 2024 Security BCP mandate this. Never use the Implicit flow (deprecated) or Resource Owner Password Credentials. |
state parameter | Always include a cryptographically random state (≥ 128 bits) on the authorization request. Bind it to the session (store in cookie or server-side). Verify on callback — reject if missing or mismatched. Stops CSRF on the OAuth callback. |
nonce (OIDC) | For OIDC flows, include nonce in the auth request and verify it in the returned ID token. Stops ID-token replay. |
| Redirect URI — exact match | The redirect_uri registered with the provider must be exact string match, no wildcards, no substring. An attacker who can register https://example.com.evil.com when the match is a prefix gets the code. |
| Redirect URI allow-list | If you proxy / accept a dynamic redirect_uri (e.g. for preview deploys), validate against a strict allow-list with full URL equality. |
| Code one-time use | Authorization codes must be redeemed exactly once and expire within ~60s. Invalidate on reuse — reuse signals interception. |
| Client secret | Server-side only — never in SPA / mobile bundles. Use PKCE-only (public client) for browser and native apps. Rotate on suspected leak. |
| Token storage | Access + refresh tokens from IdPs must be encrypted at rest (AES-GCM with KMS key) if you persist them. Don't log them. |
| Account linking — verified email only | When linking an OAuth account to a local account by email, require the IdP to mark the email as verified (email_verified: true for Google/OIDC, GitHub's verified-emails API). Otherwise an attacker registers an IdP account with victim's email and takes over. |
| Account linking — re-auth | Require the user to re-authenticate (password or existing session in good standing) before linking a new IdP, and before unlinking the only sign-in method. |
| Provider identity binding | Key the link on (provider, provider_account_id), not the email. Emails change; provider IDs don't. |
email from IdP is not authoritative | Treat the IdP-provided email as unverified unless email_verified=true is explicit. Some providers (e.g. Azure AD personal accounts) allow unverified emails. |
| ID token signature | Verify iss, aud, exp, iat, and signature (use the provider's JWKS). Cache JWKS with a sensible TTL and handle key rotation. |
scope minimization | Request the minimum scopes. Review on each provider update — profile + email is enough for sign-in; don't request offline_access unless refresh is needed. |
| Consent / incremental auth | Reauthorize ("incremental consent") for elevated scopes. Don't silently acquire drive/contact/repo scopes on first sign-in. |
| Logout propagation (RP-initiated / back-channel) | On logout, revoke refresh tokens at the IdP when possible. For SSO-heavy apps, support back-channel logout (OIDC Front-Channel / Back-Channel Logout). |
| Dynamic provider registration | Don't enable dynamic client registration at your IdP endpoint unless strictly required — it's a footgun. |
| "Sign in with X" button CSRF (login CSRF) | The login callback itself is CSRF-exposed if state isn't bound. See csrf-protection.md. |
Open-redirect via redirect_uri trick | The redirect_uri parameter on your own authorize endpoint is a classic open-redirect vector. Validate it server-side before sending the user to the IdP. |
Incorrect
// BAD: no state, no PKCE, implicit flow
const url = `https://accounts.google.com/o/oauth2/v2/auth?response_type=token&client_id=${id}&redirect_uri=${cb}`;// BAD: account linking on unverified email
async function onGithubCallback(profile) {
const existing = await db.user.findUnique({ where: { email: profile.email } });
if (existing) {
await db.account.create({ data: { userId: existing.id, provider: 'github', providerAccountId: profile.id } });
}
}Correct
// GOOD: authorization code + PKCE + state + nonce
const state = crypto.randomBytes(32).toString('hex');
const nonce = crypto.randomBytes(32).toString('hex');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
await setTempCookies({ state, nonce, verifier }); // HttpOnly, Secure, Path=/auth/callback
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
url.search = new URLSearchParams({
response_type: 'code',
client_id,
redirect_uri, // EXACT match to what's registered
scope: 'openid email profile',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();// GOOD: verified-email-only linking, keyed by provider account id
async function onGithubCallback(profile, verifiedEmails) {
const emailVerified = verifiedEmails.some(e => e.email === profile.email && e.verified);
const existingLink = await db.account.findUnique({
where: { provider_providerAccountId: { provider: 'github', providerAccountId: profile.id } },
});
if (existingLink) return signInAsUser(existingLink.userId);
if (!emailVerified) {
// Don't auto-link. Sign in as a new user or prompt for manual link.
return createNewUser({ email: profile.email, emailVerified: false });
}
const existing = await db.user.findUnique({ where: { email: profile.email } });
if (existing) {
await requireReAuth(); // step up before linking
await db.account.create({
data: { userId: existing.id, provider: 'github', providerAccountId: profile.id },
});
}
}References
Rate Limiting & Brute-Force Protection
Impact: MEDIUM
Without rate limiting, attackers can brute-force passwords, credential-stuff leaked lists, spam sign-up, abuse verification endpoints, and burn money through SMS/email providers. Naive per-IP limits are trivially bypassed by rotating through residential proxies — combine dimensions.
Checklist
| Check | Requirement |
|---|---|
| Sign-in rate limit | Combine per-account (e.g. 5 failures / 15 min per email) and per-IP (e.g. 20 / 15 min) and global. Per-IP alone is bypassed by proxies; per-account alone lets one attacker lock out all users. |
| Sign-up rate limit | Per-IP (e.g. 10/hour) + per-device-fingerprint if available. Block disposable-email domains at sign-up. |
| OTP/code verification | Limit attempts per target (5 / 15 min per phone/email) and per IP. Invalidate the code after N failed attempts — don't just block retries. |
| 429 response | Include Retry-After (seconds) header. Return the same shape as success to avoid leaking rate-limit thresholds. |
| Password reset | Rate-limit both the request (e.g. 3/hour per email, 10/hour per IP) and the token verification endpoint. |
| SMS-OTP pumping defense | Cap SMS spend per phone number + per country + per IP. Reject numbers in high-risk countries you don't serve. Prefer WhatsApp/email/authenticator over SMS. Monitor daily SMS spend — a spike means abuse. |
| Email bombardment | Rate-limit any endpoint that sends email to a user-controlled address (sign-up, reset, magic link). An attacker can enumerate emails and spam victims otherwise. |
| Account lockout | After ~10 failures, require CAPTCHA or a cool-off (15 min). Do not permanently lock — that's a DoS vector. Notify the user by email. |
| Credential stuffing | Detect patterns: many distinct accounts from one IP/ASN, same password hash across attempts, velocity spikes. Trigger CAPTCHA / 2FA step-up. Cross-check against HIBP Pwned Passwords at sign-in when feasible. |
| CAPTCHA placement | Add CAPTCHA on the 2nd+ failure, not the 1st (UX). Use invisible/v3 for legit users; visible for suspected bots. Never rely on CAPTCHA as the only defense. |
| Distributed store | Use Redis / DB-backed counters, not in-memory — each server instance otherwise has its own counter. Set a ceiling on memory growth. |
| Key choice | Rate-limit keys should include the authenticated user ID once known, not just IP. Hash IPs if you store them for limiter buckets (privacy). |
| Endpoint-specific limits | Different limits per endpoint: sign-in tighter than sign-up, MFA verification tight, read endpoints looser. |
Implementation Notes
- For IP extraction behind proxies, trust
X-Forwarded-Foronly from known load balancers. A naivereq.headers['x-forwarded-for']is attacker-controlled. - When using sliding-window or token-bucket algorithms, document the window and burst. Don't silently change them — it affects support triage.
- Prefer fail-closed on the limiter: if Redis is down, reject auth attempts rather than letting them all through.
Refer to references/features/rate-limiting.md in the create-auth skill for full implementation details and code examples across languages.
References
Session Security
Impact: HIGH
Session tokens are bearer credentials — if an attacker obtains one, they have full access to the account. Weak generation, missing cookie flags, or improper lifecycle management are common attack vectors.
Checklist
| Check | Requirement |
|---|---|
| Token generation | Cryptographically secure RNG (crypto.randomBytes(32), secrets.token_hex(32), crypto/rand). Never Math.random() / rand() / uuid v1/v4 alone if the value is used as a long-lived secret (v4 is acceptable; v1 leaks MAC + time). |
| Token length | ≥ 128 bits of entropy (32 hex chars / 16 random bytes). Prefer 256 bits (64 hex / 32 bytes). |
| Stored form | Store a hash of the session token server-side (SHA-256 is fine — tokens are already high-entropy). An attacker with a DB dump should not be able to reuse tokens. |
| Session expiry (absolute) | Hard max lifetime: 7–30 days for general apps, ≤ 12–24 h for sensitive apps. Enforce server-side — a JWT whose exp has passed must be rejected even if the cookie is presented. |
| Idle timeout | Expire after inactivity (e.g. 30 min for banking, 24 h for general). Sliding window is OK but capped by the absolute expiry. |
| Cookie flags | HttpOnly (always). Secure (always in prod, including localhost over HTTPS). SameSite=Lax minimum, Strict for privileged session cookies. Explicit Path=/ and, if cross-subdomain is not needed, no Domain attribute. |
__Host- prefix | Use __Host-session for the session cookie. Enforces Secure, Path=/, and no Domain — blocks subdomain cookie-injection attacks. |
Partitioned attribute (CHIPS) | Set Partitioned on cookies used in third-party / embedded contexts. Chrome now isolates cross-site cookies by default; without Partitioned the cookie is dropped in iframes. |
| Session invalidation on sign-out | Delete the session row / add the token to a revocation list. Also clear the cookie (Max-Age=0) and return Clear-Site-Data: "cookies", "storage". |
| Session fixation | Sign-in must issue a new session ID and invalidate any pre-auth session. Same for privilege changes (step-up, role change, impersonation end). |
| Session invalidation on password change | Changing password, email, or MFA factor must revoke all other active sessions except the current one. Offer a "sign out of all other devices" affordance. |
| Refresh token rotation | If using refresh tokens, rotate on every use and detect reuse — if an old refresh token is replayed, revoke the entire family (indicates theft). |
| JWT-specific checks | alg must be validated on the server — never trust the header. Disable alg: none. Prefer EdDSA or RS256/ES256 over HS256 for asymmetric use cases. Validate iss, aud, exp, nbf, iat. |
| JWT ≠ revocation | JWTs are immutable until exp. If you need instant revocation (logout, compromise), keep a server-side allow/deny list or use short-lived access tokens (≤ 15 min) + refresh tokens. |
| Token storage (client) | Prefer HttpOnly cookies. If SPA + Authorization header is required, keep access tokens in memory only (not localStorage/sessionStorage — XSS readable). Use a silent refresh via HttpOnly cookie. |
| Concurrent session limit | Consider capping active sessions per user. Surface a "signed-in devices" UI so users can revoke individually. |
| Device/IP binding (optional) | For high-sensitivity apps, bind the session to a coarse device fingerprint or ASN. Don't bind to exact IP — mobile users' IPs change mid-session. |
Incorrect
// BAD: predictable token
const token = Date.now().toString(36) + Math.random().toString(36);// BAD: cookie missing security flags, no prefix, stored raw
res.setHeader('Set-Cookie', `session=${token}`);
await db.session.create({ data: { token, userId } }); // raw token in DB// BAD: JWT with no revocation path and no alg check
const payload = jwt.decode(req.headers.authorization.slice(7)); // no verify!Correct
// GOOD: crypto-random token, hashed before storage
import crypto from 'crypto';
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await db.session.create({ data: { tokenHash, userId, expiresAt } });
res.setHeader('Set-Cookie',
`__Host-session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 60 * 60}`
);// GOOD: JWT verified with explicit algorithm allow-list
const decoded = jwt.verify(token, publicKey, {
algorithms: ['EdDSA'], // explicit — prevents alg confusion
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});// GOOD: session fixation prevention — new ID on sign-in
async function signIn(email, password) {
const user = await verifyPassword(email, password);
await invalidateCurrentSession(req); // kill pre-auth session
const newToken = crypto.randomBytes(32).toString('hex');
await createSession(user.id, hash(newToken));
return newToken;
}
// GOOD: revoke siblings on password change
async function changePassword(userId, newPassword, currentSessionId) {
await updatePasswordHash(userId, newPassword);
await db.session.deleteMany({
where: { userId, id: { not: currentSessionId } },
});
}References
Short-Lived Token Lifecycle
Impact: HIGH
Email-verification links, password-reset links, magic links, OTPs, and invitation tokens are all variants of the same primitive: a server-issued token a user presents to perform an action. Getting any of them wrong turns "forgot my password" into "take over any account".
Checklist — Common
| Check | Requirement |
|---|---|
| Generation | Cryptographically random, ≥ 128 bits entropy (32 hex / 22 base64url). For numeric OTP: 6–8 digits. Never time-derived, sequential, or predictable. |
| Storage | Store a hash of the token (SHA-256) server-side, not the raw token. Compare using constant-time equality. A DB dump must not yield reusable tokens. |
| One-time use | Mark consumed on first successful use. Subsequent attempts fail even within the TTL. |
| Short TTL | Reset: ≤ 30 min. Email verification: ≤ 24 h. Magic link: ≤ 15 min. OTP: ≤ 10 min (shorter is better). Invitations: ≤ 7 days. Enforce server-side — don't trust a JWT exp. |
| Scope binding | Token stores the userId (and action — "reset", "verify", "invite"). Reject cross-type use (a "verify" token cannot complete a reset). |
| Invalidate on state change | Password change / MFA change / email change must invalidate all outstanding tokens for that user. |
| Invalidate siblings on use | Using one reset token invalidates all other pending reset tokens for that account. |
| Delivery channel binding | A token emailed to foo@example.com should only be usable to act on the account whose current email is foo@example.com at consumption time, not just at issuance. Otherwise an attacker who emails-change mid-flow races the token. |
| Rate-limit issuance | See rate-limiting.md. Cap requests per account, per IP, and global — both to prevent enumeration and to prevent email/SMS bombardment. |
| Constant-time lookup | Lookup by token-hash returns the same latency whether found or not. Don't branch on existence before hashing. |
| Don't log tokens | No raw tokens in application logs, error logs, or third-party error trackers. Log only the token ID (a non-sensitive UUID) for support. |
| Don't put tokens in URL fragments that then POST | A token in ?t=... ends up in browser history, Referer, and analytics. Mitigate: strip from Referer (Referrer-Policy: no-referrer on reset pages), redirect away from the token URL once consumed, and keep TTL short. |
Checklist — Password Reset
| Check | Requirement |
|---|---|
| Response uniformity | "If an account exists with that email, we sent a reset link" — identical response whether or not the account exists. See error-handling.md. |
| Invalidate sessions on completion | On successful reset, revoke all active sessions for that user except (optionally) the current one. |
| Require MFA after reset | If the account has MFA, enforce it after password reset before issuing a session. See mfa-passkeys.md. |
| No password leak in the URL | The link carries the reset token, not the new password. The new password is submitted via POST over HTTPS. |
| Notify on completion | Send an email to the user's current address when a reset completes. Include IP/UA and a "wasn't me" link. |
Checklist — Email Verification / Change
| Check | Requirement |
|---|---|
| Pending email change | Store the new email separately until verified. Don't update the primary email until the new address confirms. |
| Verify both sides on email change | Send a "you requested to change" notice to the old email (with a cancel link), and a "confirm" link to the new email. Otherwise an attacker who temporarily controls the session can silently change email. |
| Email change invalidates tokens/sessions | On email change completion, invalidate all sessions and pending reset/verification tokens; require re-login. |
Checklist — Magic Link
| Check | Requirement |
|---|---|
| Same-device requirement (optional) | Consider binding the link to a device cookie set at request time. The link only works in the same browser that requested it. Blocks phishing where the attacker gets the user to click their link. |
| Short TTL | ≤ 15 min. Magic links should not outlive their intended use. |
| Signal phishing | If a user opens a magic link they didn't request, show "You're signing in as X" and require an explicit click (not auto-login). |
| Magic link + passkey | For security-sensitive apps, disable magic link if passkeys are enrolled. |
Checklist — OTP / Verification Codes
| Check | Requirement |
|---|---|
| Length | 6 digits is the UX floor; 8 digits for higher-value flows. With a 5-attempt lockout, 6 digits is tolerable. |
| Attempt cap | Invalidate the code after ~5 wrong attempts. Don't just block further requests — the code must be dead. |
| Single outstanding code | Issuing a new code invalidates the previous one. |
| Channel | Email/authenticator > SMS. SMS is vulnerable to SIM swap and pumping (see rate-limiting.md). |
| Cross-channel prevent | A code sent via email is not usable to verify a phone number, and vice versa. Bind (code, target, channel). |
Incorrect
// BAD: raw token in URL stored in plain text, no expiry, no one-time use
const token = crypto.randomUUID();
await db.resetToken.create({ data: { token, userId } });
sendEmail(`https://example.com/reset?token=${token}`);
// Later — found it? Great, let them set a password.
async function confirmReset(token, newPassword) {
const row = await db.resetToken.findUnique({ where: { token } });
if (!row) throw new Error('invalid');
await setPassword(row.userId, newPassword);
// row not deleted — token is reusable forever
}Correct
// GOOD: hashed storage, one-time, short TTL, invalidated on use, sessions revoked
async function issueReset(email) {
const user = await db.user.findUnique({ where: { email } });
if (user) {
const raw = crypto.randomBytes(32).toString('base64url');
const hash = crypto.createHash('sha256').update(raw).digest('hex');
await db.resetToken.deleteMany({ where: { userId: user.id } }); // invalidate siblings
await db.resetToken.create({
data: { userId: user.id, tokenHash: hash, expiresAt: new Date(Date.now() + 30 * 60_000) },
});
await sendEmail(user.email, `https://example.com/reset?t=${raw}`);
}
// Always same response:
return { status: 'ok' };
}
async function confirmReset(raw, newPassword) {
const hash = crypto.createHash('sha256').update(raw).digest('hex');
const row = await db.resetToken.findUnique({ where: { tokenHash: hash } });
if (!row || row.consumedAt || row.expiresAt < new Date()) {
throw new HttpError(400, 'Invalid or expired link');
}
await db.$transaction([
db.resetToken.update({ where: { id: row.id }, data: { consumedAt: new Date() } }),
db.user.update({ where: { id: row.userId }, data: { passwordHash: await hashPassword(newPassword) } }),
db.session.deleteMany({ where: { userId: row.userId } }),
db.resetToken.deleteMany({ where: { userId: row.userId, consumedAt: null } }),
]);
await notifyPasswordChanged(row.userId);
}