security-toolkit/skills/secure-auth/SKILL.md
Secure authentication implementation patterns. Use when implementing user login, registration, password reset, session management, JWT authentication, OAuth, MFA, or passkeys. Provides production-ready patterns aligned with NIST SP 800-63B-4, OWASP 2026 cheat sheets, OAuth 2.1, and WebAuthn L3, with breach-driven lessons.
npx skillsauth add jamditis/claude-skills-journalism secure-authInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Security knowledge ages on a 6-12 month half-life. The recipes below were last verified on 2026-05-08; they may be stale by the time you read this. Before applying any pattern in this skill, fan out research scoped to the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys) so the recipes are interpreted against current authoritative sources, not against this file's snapshot.
Run the 4-angle research below by default. Skip ONLY when ALL of these hold:
Research skipped because <reason> note in your response."I think I know" / "moving fast" / "user wants this done quickly" / "already familiar" are NOT valid skip reasons. The whole point of this preamble is that future-you should not trust this skill body's defaults until current state is checked.
Each subagent returns at most 300 words of bullets with citations. Dispatch all 4 in a single message so they run concurrently.
Angle 1 — Authoritative standards. Have NIST / OWASP / IETF (RFCs and Internet-Drafts) / W3C / CISA published anything new about the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys) in the last 6-12 months? Look for: spec finalizations, deprecations, replacement specs, RFC publications, draft revisions, NIST SP updates, OWASP project version bumps. Cite by document number plus publication date.
Angle 2 — Active exploitation. What's actively being exploited that targets the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys)? Pull from: CISA Known Exploited Vulnerabilities (KEV) catalog (filter to last 6-12 months), recent CVE / GHSA entries with high CVSS or in-the-wild exploitation, breach postmortems and incident reports (CSRB, vendor RCAs, security-vendor research). Surface CWE patterns dominating recent KEV adds. Cite by CVE number plus advisory URL.
Angle 3 — Tooling and library state. Are the libraries this skill recommends still current? What are the latest major versions in the relevant package registry (npm / PyPI / RubyGems / crates.io)? Have any been deprecated, replaced, or merged into another project? Have any flipped a secure default? Look up current versions in: registry.npmjs.org, pypi.org, rubygems.org, crates.io, pkg.go.dev. Cite by package plus version plus release date.
Angle 4 — Practitioner discourse. What are practitioners and security teams talking about in the last 6 months? Pull from: OWASP Cheat Sheet Series (last-modified date matters), GitHub Security Lab posts, vendor security blogs (Cloudflare, Fastly, Snyk, Datadog, Wiz, GitGuardian), conference talks (Black Hat, DEF CON, OWASP Global AppSec, USENIX Security), SANS ISC, Krebs, recent OWASP project re-releases. Surface the patterns being adopted and the anti-patterns being called out. Cite by post URL plus author plus date.
After the 4 returns land, write a 1-paragraph "current state for the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys), as of <today's date>" that names:
If the synthesis flags drift in this skill body's recipes (e.g., a spec finalized after 2026-05-08, a library now deprecated, a default flipped), call that out explicitly in your response and override the skill body where they conflict. The synthesis wins. The skill body is scaffolding, not scripture.
If subagents are not available in your runtime, the same shape applies in-line: do 4 sequential targeted searches (web search for standards, KEV catalog lookup, package registry version checks, recent cheat-sheet diff). Land the same 1-paragraph synthesis. Cost goes up; the protection does not change.
Production-ready authentication patterns. These aren't the simplest implementations — they're the ones that won't get you sued.
The 2020-era "session vs JWT" frame is no longer the only axis. In 2026 the question is closer to "passkey plus short-lived bound tokens" vs "session cookie." Pick by deployment shape, not by what a tutorial used.
Use sessions when:
Use JWTs when:
Use passkeys (WebAuthn / FIDO2) as the primary factor when:
The passkey-first stance reflects 2026 consensus: WebAuthn L3 reached W3C Candidate Recommendation Snapshot 2026-01-13 (https://www.w3.org/TR/webauthn-3/) and CTAP 2.3 became a FIDO Alliance Proposed Standard 2026-02-26. See the Passkeys / WebAuthn section below.
Common mistake: Using JWTs because a tutorial did, then storing them in localStorage (XSS-vulnerable) and having no revocation strategy. Refresh-token reuse detection and full token validation (issuer, audience, scope, signing-key tenancy) are non-optional in 2026 — see the Storm-0558 lesson.
The single source of truth for password hashing across this skill. The Session and JWT examples below assume these defaults.
OWASP Password Storage Cheat Sheet (last updated 2026-05-07, https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) lists any of 5 equivalent argon2id profiles. Pick whichever fits your server's memory budget; they're calibrated to similar work factors:
// Node — argon2 package (current 0.44.0; pre-1.0, pin by minor)
const argon2 = require('argon2');
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // KiB — one of the 5 OWASP-equivalent profiles
timeCost: 2,
parallelism: 1
});
// Verify
const valid = await argon2.verify(hash, password);
# Python — argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
memory_cost=19456, # KiB
time_cost=2,
parallelism=1,
)
hashed = ph.hash(password)
try:
ph.verify(hashed, password)
except VerifyMismatchError:
# invalid password
pass
RFC 9106 (https://datatracker.ietf.org/doc/rfc9106/) defines a more aggressive "FIRST RECOMMENDED" profile (t=1, p=4, m=2 GiB) intended for server environments with that memory available; OWASP's 5 profiles are the practical floor.
Still acceptable. OWASP says cost 10 minimum, "as large as performance allows." The current code uses cost 12, which is fine — just know the floor moved.
// Node — bcrypt (current 6.0.0)
const bcrypt = require('bcrypt');
// bcrypt has a 72-byte input limit. Pre-hash with SHA-256
// when accepting longer passphrases, OR reject inputs over 72 bytes.
const crypto = require('crypto');
function safeBcryptInput(password) {
const bytes = Buffer.byteLength(password, 'utf8');
if (bytes > 72) {
// Pre-hash to a fixed 32-byte digest, base64-encoded (44 ASCII bytes)
return crypto.createHash('sha256').update(password).digest('base64');
}
return password;
}
const hashed = await bcrypt.hash(safeBcryptInput(password), 12);
const ok = await bcrypt.compare(safeBcryptInput(password), hashed);
Acceptable. OWASP minimum is N=2^17, r=8, p=1.
PBKDF2 is the algorithm to use when FIPS-140 compliance is a hard requirement. Otherwise prefer argon2id. OWASP minimum: 600,000 iterations of PBKDF2-HMAC-SHA256, or 210,000 of PBKDF2-HMAC-SHA512.
NIST SP 800-63B-4 went FINAL 2025-07-31 (https://csrc.nist.gov/pubs/sp/800/63/b/4/final). The old 800-63B was withdrawn 2025-08-01. The values below are normative. Don't deviate.
The verifier MUST check candidate passwords against a list of known-compromised values. Use the HaveIBeenPwned k-anonymity API (https://haveibeenpwned.com/API/v3#PwnedPasswords) — you submit the first 5 chars of a SHA-1 hash, get back the suffixes that match, never send the password itself.
// Check candidate password against HIBP
const crypto = require('crypto');
async function isPwned(password) {
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}`, {
headers: { 'Add-Padding': 'true' }
});
if (!res.ok) {
// Fail closed on availability blip? Up to your threat model.
// Default: don't block registration if HIBP is down; log and proceed.
return false;
}
const body = await res.text();
return body.split('\n').some(line => line.startsWith(suffix));
}
NIST 800-63B-4 REQUIRES phishing resistance at AAL3. Passwords alone never reach AAL3. AAL2 with phishing resistance is what most consumer apps should target now — that means WebAuthn (passkey) or PIV/CAC, not TOTP and not SMS.
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const argon2 = require('argon2');
const crypto = require('crypto');
const app = express();
// Redis client for session storage
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// Session configuration
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // At least 32 random bytes
name: 'sessionId', // Don't use default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Rate limiting for auth endpoints
const loginAttempts = new Map();
function checkRateLimit(ip) {
const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };
if (Date.now() > attempts.resetAt) {
attempts.count = 0;
attempts.resetAt = Date.now() + 900000; // 15 minute window
}
if (attempts.count >= 5) {
return false;
}
attempts.count++;
loginAttempts.set(ip, attempts);
return true;
}
// Argon2id parameters — one of the 5 OWASP-equivalent profiles
const ARGON2_OPTS = {
type: argon2.argon2id,
memoryCost: 19456, // KiB
timeCost: 2,
parallelism: 1
};
// Precompute a real argon2id hash for the user-not-found timing-attack defense.
// Must be a valid hash string at the same parameters used for storage so that
// argon2.verify does the full work — a malformed string would short-circuit at
// parse time and reintroduce the timing channel.
let DUMMY_VERIFY_HASH = null;
(async () => {
DUMMY_VERIFY_HASH = await argon2.hash('argon2-timing-defense-init', ARGON2_OPTS);
})();
// Registration
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// NIST 800-63B-4: 15-char minimum for single-factor passwords
if (password.length < 15) {
return res.status(400).json({ error: 'Password must be at least 15 characters' });
}
// Reject up-front pwned passwords (HIBP k-anonymity)
if (await isPwned(password)) {
return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
}
// Check if user exists
const existingUser = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
// Don't reveal if email exists - use same message/timing
return res.status(400).json({ error: 'Registration failed' });
}
// Hash password
const hashedPassword = await argon2.hash(password, ARGON2_OPTS);
// Create user
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email.toLowerCase(), hashedPassword]
);
// Create session
req.session.userId = result.rows[0].id;
req.session.createdAt = Date.now();
res.json({ success: true });
});
// Login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const clientIp = req.ip;
// Rate limiting
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user
const result = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
// Timing attack prevention: full-cost verify against a real precomputed hash.
// A malformed hash string would make argon2.verify fail at parse time —
// that's faster than the valid-user path and leaks "user not found" via
// timing. DUMMY_VERIFY_HASH is computed once at module init below so
// this path does the same work as the real verify.
if (DUMMY_VERIFY_HASH) {
await argon2.verify(DUMMY_VERIFY_HASH, password).catch(() => false);
}
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
// Verify password
const isValid = await argon2.verify(user.password_hash, password).catch(() => false);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session to prevent fixation.
// Also regenerate on privilege CHANGE (e.g. role escalation, MFA upgrade), not just login.
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
// Clear rate limit on successful login
loginAttempts.delete(clientIp);
res.json({ success: true });
});
});
// Logout
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Optional: Check session age
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
next();
}
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.session.userId]
);
res.json(user.rows[0]);
});
Microsoft's Storm-0558 incident (CSRB review, https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion) traced to OWA accepting a consumer-key-signed token for enterprise mailboxes — the token-validation library skipped the issuer / audience / scope / signing-key-tenancy checks. Validate every claim every time:
iss (issuer) — MUST match your expected issuer stringaud (audience) — MUST include your service identifierexp, nbf (expiration / not-before) — MUST be enforced; reject expired or future-dated tokensscope — MUST contain the scope required for the endpointconst jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Token configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
const ISSUER = 'https://auth.yourapp.com';
const AUDIENCE = 'https://api.yourapp.com';
// Store refresh tokens (use Redis in production).
// Each entry tracks the family chain so reuse can revoke siblings.
const refreshTokens = new Map();
function generateAccessToken(userId, scopes = []) {
return jwt.sign(
{ userId, scope: scopes.join(' '), type: 'access' },
ACCESS_TOKEN_SECRET,
{
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: ISSUER,
audience: AUDIENCE
}
);
}
function generateRefreshToken(userId, familyId = null) {
const tokenId = crypto.randomBytes(32).toString('hex');
const family = familyId || crypto.randomBytes(16).toString('hex');
const token = jwt.sign(
{ userId, tokenId, familyId: family, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{
expiresIn: REFRESH_TOKEN_EXPIRY,
issuer: ISSUER,
audience: AUDIENCE
}
);
refreshTokens.set(tokenId, {
userId,
familyId: family,
createdAt: Date.now(),
used: false,
revoked: false
});
return { token, familyId: family };
}
// Revoke an entire refresh-token family (used on reuse detection).
function revokeFamily(familyId) {
for (const [id, entry] of refreshTokens) {
if (entry.familyId === familyId) {
entry.revoked = true;
}
}
}
// Login - returns both tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// ... validation and password check ...
const accessToken = generateAccessToken(user.id, ['read', 'write']);
const { token: refreshToken } = generateRefreshToken(user.id);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Return access token in response body
res.json({ accessToken });
});
// Refresh endpoint with rotation + reuse detection
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
let decoded;
try {
decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE
});
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const stored = refreshTokens.get(decoded.tokenId);
if (!stored || stored.revoked) {
return res.status(401).json({ error: 'Token revoked' });
}
// REUSE DETECTION: if a previously-rotated refresh token is presented,
// the family is compromised. Revoke every sibling immediately.
if (stored.used) {
revokeFamily(decoded.familyId);
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
stored.used = true;
// Rotate: issue a new access + refresh in the same family.
const newAccess = generateAccessToken(decoded.userId, ['read', 'write']);
const { token: newRefresh } = generateRefreshToken(decoded.userId, decoded.familyId);
res.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccess });
});
// Logout - revoke entire refresh-token family
app.post('/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE
});
revokeFamily(decoded.familyId);
} catch (err) {
// Token invalid, no action needed
}
}
res.clearCookie('refreshToken');
res.json({ success: true });
});
// Auth middleware for protected routes.
//
// Scope of THIS sample: single-issuer private-token deployments using HS256
// (shared secret). It enforces iss / aud / exp / scope / pinned algorithm —
// the claim-level half of the Storm-0558 lesson.
//
// What this sample does NOT cover and you MUST add for multi-issuer / OIDC
// (Azure AD / Auth0 / Cognito / etc.): the signing-key tenancy half. There:
// 1. Use a public-key algorithm (RS256, ES256, EdDSA) — not HS256.
// 2. Resolve the issuer's JWKS at the issuer's well-known URL.
// 3. Pin which JWKS each trusted issuer is allowed to use, and reject any
// token whose iss does not match the JWKS that signed it. Storm-0558's
// proximate failure was OWA accepting a consumer-tenant key for an
// enterprise-tenant token because the verifier did NOT enforce this
// issuer-to-key-set binding.
// 4. Select the verifying key by the JWT's `kid` — but only from within
// the pinned key set for that issuer.
function requireAuth(requiredScope) {
return function (req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.substring(7);
let decoded;
try {
decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['HS256'] // pin algorithm; reject 'none' and unexpected algs
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
if (decoded.type !== 'access') {
return res.status(401).json({ error: 'Invalid token type' });
}
if (requiredScope) {
const scopes = (decoded.scope || '').split(' ');
if (!scopes.includes(requiredScope)) {
return res.status(403).json({ error: 'Insufficient scope' });
}
}
req.userId = decoded.userId;
req.scopes = (decoded.scope || '').split(' ');
next();
};
}
// Usage: app.get('/api/admin', requireAuth('admin'), handler)
Bearer tokens are stealable. For payments, healthcare, government, or any context where token theft is catastrophic, use sender-constrained tokens:
cnf.jkt claim). Token alone is useless without the key.cnf.x5t#S256 claim).Both make stolen tokens worthless to the attacker.
// auth.js - Frontend token management
class AuthManager {
constructor() {
this.accessToken = null;
}
async login(email, password) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for cookies
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return true;
}
async refreshToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
this.accessToken = null;
throw new Error('Session expired');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return accessToken;
}
async fetchWithAuth(url, options = {}) {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
// If token expired, try to refresh and retry
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
await this.refreshToken();
// Retry original request
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
}
return response;
}
async logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
}
}
export const auth = new AuthManager();
Don't store access tokens in localStorage — they're readable by any XSS payload. In-memory (as above) plus an httpOnly refresh cookie is the standard shape. For SPAs that need to survive a page refresh, use the BroadcastChannel API to share the in-memory token across tabs and accept that a hard refresh forces a /auth/refresh call.
WebAuthn L3 is at W3C Candidate Recommendation Snapshot as of 2026-01-13 (https://www.w3.org/TR/webauthn-3/). Comments accepted through 2026-02-10. CTAP 2.3 became a FIDO Alliance Proposed Standard 2026-02-26.
Recommended libraries:
@simplewebauthn/server (current 13.3.0). Companion browser package: @simplewebauthn/browser.webauthn on PyPI (current 2.7.1). Note: the GitHub repo is duo-labs/py_webauthn, but the install command is pip install webauthn — the PyPI package name is webauthn, NOT py_webauthn.// Node — @simplewebauthn/server
const {
generateRegistrationOptions,
verifyRegistrationResponse,
} = require('@simplewebauthn/server');
const RP_ID = 'yourapp.com'; // your effective domain
const RP_NAME = 'YourApp';
const ORIGIN = 'https://yourapp.com';
// 1. Server: build options for the browser
app.post('/auth/passkey/register/options', requireAuth(), async (req, res) => {
const user = await db.query('SELECT id, email, name FROM users WHERE id = $1', [req.userId]);
const existing = await db.query(
'SELECT credential_id, transports FROM passkeys WHERE user_id = $1',
[req.userId]
);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(String(user.rows[0].id)),
userName: user.rows[0].email,
userDisplayName: user.rows[0].name,
attestationType: 'none',
excludeCredentials: existing.rows.map(c => ({
id: c.credential_id,
transports: c.transports || undefined,
})),
authenticatorSelection: {
residentKey: 'preferred', // discoverable credentials enable conditional UI
userVerification: 'preferred',
},
});
// Store challenge for the verify step (Redis with short TTL)
await redisClient.setEx(
`webauthn:reg:${req.userId}`,
300,
options.challenge
);
res.json(options);
});
// 2. Server: verify what the browser returned
app.post('/auth/passkey/register/verify', requireAuth(), async (req, res) => {
const expectedChallenge = await redisClient.get(`webauthn:reg:${req.userId}`);
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'Registration not verified' });
}
const { credential, credentialBackedUp, credentialDeviceType } =
verification.registrationInfo;
await db.query(
`INSERT INTO passkeys
(user_id, credential_id, public_key, counter, transports, device_type, backed_up)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
req.userId,
credential.id,
credential.publicKey,
credential.counter,
req.body.response?.transports || null,
credentialDeviceType,
credentialBackedUp,
]
);
await redisClient.del(`webauthn:reg:${req.userId}`);
res.json({ verified: true });
});
<!-- Browser — @simplewebauthn/browser -->
<script type="module">
import { startRegistration } from 'https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.js';
async function enrollPasskey() {
const optsRes = await fetch('/auth/passkey/register/options', { method: 'POST', credentials: 'include' });
const options = await optsRes.json();
const attResp = await startRegistration({ optionsJSON: options });
const verRes = await fetch('/auth/passkey/register/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});
return verRes.json();
}
document.getElementById('enroll').addEventListener('click', enrollPasskey);
</script>
Conditional UI lets the browser surface passkeys directly inside the username autofill dropdown — the user picks a passkey from the same UI they'd use to autofill an email.
// Node — server side
const {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
app.post('/auth/passkey/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
// allowCredentials omitted → discoverable credentials → conditional UI works
});
// Tie the challenge to a temporary session marker
const challengeId = crypto.randomBytes(16).toString('hex');
await redisClient.setEx(`webauthn:auth:${challengeId}`, 300, options.challenge);
res.json({ options, challengeId });
});
app.post('/auth/passkey/login/verify', async (req, res) => {
const { challengeId, response } = req.body;
const expectedChallenge = await redisClient.get(`webauthn:auth:${challengeId}`);
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge expired' });
}
const credentialId = response.id;
const stored = await db.query(
`SELECT user_id, public_key, counter, transports FROM passkeys WHERE credential_id = $1`,
[credentialId]
);
if (stored.rows.length === 0) {
return res.status(401).json({ error: 'Unknown credential' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: credentialId,
publicKey: stored.rows[0].public_key,
counter: stored.rows[0].counter,
transports: stored.rows[0].transports || undefined,
},
requireUserVerification: false,
});
} catch (err) {
return res.status(401).json({ error: err.message });
}
if (!verification.verified) {
return res.status(401).json({ error: 'Authentication not verified' });
}
// Persist new counter
await db.query(
`UPDATE passkeys SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`,
[verification.authenticationInfo.newCounter, credentialId]
);
// Issue session/tokens as usual
req.session.regenerate(() => {
req.session.userId = stored.rows[0].user_id;
res.json({ verified: true });
});
});
<!-- Browser — discoverable credential + conditional UI -->
<input type="email" name="email" autocomplete="username webauthn">
<script type="module">
import { startAuthentication, browserSupportsWebAuthnAutofill }
from 'https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.js';
if (await browserSupportsWebAuthnAutofill()) {
const optsRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
const { options, challengeId } = await optsRes.json();
const authResp = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true, // conditional UI
});
await fetch('/auth/passkey/login/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengeId, response: authResp }),
});
location.href = '/dashboard';
}
</script>
L3 adds PublicKeyCredential static methods so the relying party can keep syncable passkeys aligned with server state without forcing a re-enrollment:
signalUnknownCredential({ rpId, credentialId }) — call after a verify attempt against a credential the server no longer knows about. The authenticator hides it from the user's account-picker UI.signalAllAcceptedCredentials({ rpId, userId, allAcceptedCredentialIds }) — call after the server's credential list changes (revoke, enroll). The authenticator prunes anything not on the list.signalCurrentUserDetails({ rpId, userId, name, displayName }) — call after profile changes. The authenticator updates labels in the picker UI.These are advisory and best-effort; treat them as housekeeping, not security boundaries.
webauthn (Python)# pip install webauthn # PyPI package name is `webauthn`, NOT py_webauthn
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)
RP_ID = "yourapp.com"
RP_NAME = "YourApp"
ORIGIN = "https://yourapp.com"
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=str(user_id).encode(),
user_name=user_email,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
)
const crypto = require('crypto');
// Request password reset
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return success to prevent email enumeration
res.json({ message: 'If an account exists, a reset link has been sent.' });
// Find user (async, after response)
const result = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return; // User doesn't exist, but don't reveal that
}
const user = result.rows[0];
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
// Store hashed token (not plain token)
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
// Send email with plain token
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, 'Password Reset', `Reset your password: ${resetUrl}`);
});
// Reset password
app.post('/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ error: 'Token and new password required' });
}
// NIST 800-63B-4: 15-char minimum
if (newPassword.length < 15) {
return res.status(400).json({ error: 'Password must be at least 15 characters' });
}
if (await isPwned(newPassword)) {
return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
}
// Hash the provided token to compare with stored hash
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Find valid reset token
const result = await db.query(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
[tokenHash]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const userId = result.rows[0].user_id;
// Hash new password with argon2id
const hashedPassword = await argon2.hash(newPassword, ARGON2_OPTS);
// Update password and invalidate token
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);
// Invalidate all existing sessions and refresh-token families for this user
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
// (also revoke all refresh-token families for the user in your token store)
res.json({ success: true });
});
OAuth 2.1 is currently draft-ietf-oauth-v2-1-15 dated 2026-03-02 (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15). Not yet an RFC, but providers (Google, Microsoft, Okta, Auth0) already implement these constraints today, so design to the 2.1 baseline.
plain is deprecated.response_type=token). Use authorization code with PKCE.const { OAuth2Client } = require('google-auth-library');
const crypto = require('crypto');
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
const { verifier, challenge } = pkcePair();
req.session.oauthState = state;
req.session.oauthVerifier = verifier;
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['email', 'profile'],
state,
prompt: 'consent',
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (!state || state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
const verifier = req.session.oauthVerifier;
delete req.session.oauthState;
delete req.session.oauthVerifier;
if (!verifier) {
return res.status(400).send('Missing PKCE verifier');
}
try {
// Exchange code for tokens — include the PKCE verifier
const { tokens } = await oauth2Client.getToken({
code,
codeVerifier: verifier,
});
oauth2Client.setCredentials(tokens);
// Validate the id_token: Google verifies signature; we MUST check audience
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (payload.iss !== 'https://accounts.google.com' && payload.iss !== 'accounts.google.com') {
return res.status(400).send('Unexpected issuer');
}
const { sub: googleId, email, name, picture } = payload;
// Find or create user
let user = await db.query('SELECT id FROM users WHERE google_id = $1', [googleId]);
if (user.rows.length === 0) {
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4) RETURNING id`,
[googleId, email, name, picture]
);
}
// Create session
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('Session error');
}
req.session.userId = user.rows[0].id;
res.redirect('/dashboard');
});
} catch (error) {
console.error('OAuth error:', error);
res.status(400).send('Authentication failed');
}
});
The same pattern applies to any OAuth 2.1 / OIDC provider — substitute issuer, client, scopes, and the userinfo lookup. Confirm exact-string redirect URIs in the provider console match what your server sends.
CISA "Implementing Phishing-Resistant MFA" (2022-10-31, still current 2026-05; https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf) ranks factor strength:
In 2026: don't add SMS as a new factor. Migrate users off it where you can.
If passwords are still your first factor, a registered passkey is the strongest second factor available. The registration and authentication ceremonies in the Passkeys / WebAuthn section above work unchanged — gate /auth/login on a successful passkey verify after password verify.
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Enable MFA for user
app.post('/auth/mfa/enable', requireAuth(), async (req, res) => {
// Generate secret
const secret = speakeasy.generateSecret({
name: `YourApp:${req.user.email}`,
issuer: 'YourApp'
});
// Store secret (encrypted) temporarily until verified
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[encrypt(secret.base32), req.userId]
);
// Generate QR code
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // Show this as backup
qrCode: qrCode
});
});
// Verify and activate MFA
app.post('/auth/mfa/verify', requireAuth(), async (req, res) => {
const { code } = req.body;
const result = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.userId]
);
const secret = decrypt(result.rows[0].mfa_secret_temp);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // Allow 1 step tolerance
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code' });
}
// Move secret from temp to permanent
await db.query(
'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
[req.userId]
);
res.json({ success: true });
});
// Login with MFA
app.post('/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// ... verify email/password first ...
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(401).json({
error: 'MFA code required',
requiresMfa: true
});
}
const verified = speakeasy.totp.verify({
secret: decrypt(user.mfa_secret),
encoding: 'base32',
token: mfaCode,
window: 1
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
// ... create session/token ...
});
If you already have SMS in production, plan its retirement. Snowflake (see breach lessons) and the broader SIM-swap landscape make SMS a liability, not a safeguard. NIST 800-63B-4 §5 restricts it; CISA recommends moving off it.
Vignettes anchoring patterns to actual incidents. Each is a 2-3 sentence summary plus the lesson encoded in the recipes above.
Attackers used compromised credentials on a Citrix remote-access portal that lacked MFA. Disruption ran for weeks; UnitedHealth Group disclosed an approximately $22M ransom and exposure of records for roughly 1 in 3 US patients. Per the UnitedHealth Group RCA at https://www.unitedhealthgroup.com/newsroom/2024/2024-04-22-uhg-update-on-change-healthcare-cyberattack.html.
Lesson: MFA on every remote-access portal — VPN, Citrix, RDP gateway, jump host — not just user-facing apps. The "internal" portal is the one attackers target precisely because it's less guarded.
Approximately 165 customer tenants were breached because Snowflake's MFA was opt-in per tenant; attackers used infostealer-harvested credentials against accounts with no second factor. Mandiant's writeup is at https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion. Snowflake enforced default-on MFA from October 2024 and has been phasing in mandatory blocking of password-only sign-in across 2025-2026; see Snowflake's MFA enforcement documentation (https://docs.snowflake.com/) for the current rollout schedule.
Lesson: MFA must default to ON, not opt-in. If your customers can disable it, attackers will find the ones who did.
A Microsoft consumer signing key was compromised; attackers forged Azure AD tokens and accessed enterprise OWA mailboxes. The proximate failure was that OWA's token-validation library accepted a consumer-key-signed token for enterprise mailboxes — it skipped scope, issuer, and signing-key tenancy validation. CSRB report: https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion. (Microsoft's MSRC postmortem URL is decommissioned.)
Lesson: Validate every claim every time — issuer, audience, scope, expiration, pinned algorithm. AND for multi-issuer / OIDC deployments, validate signing-key tenancy: pin which JWKS each trusted issuer is allowed to use, and never select a verifying key from a different issuer's set, even if the kid matches. The JWT recipe above bakes in iss / aud / scope / pinned algorithm — that's the claim-level half of the lesson and covers single-issuer private-token deployments. The signing-key tenancy half (JWKS resolution and issuer-to-key-set binding) is not in the sample; see the comment block above the requireAuth function for what to add when accepting tokens from multiple tenants.
Customers uploaded HAR (HTTP Archive) debug files to Okta support; the files contained live session tokens. Five customer sessions were hijacked. Per Okta's writeup at https://sec.okta.com/articles/harfiles/.
Lesson: Sanitize session tokens and other bearer credentials at log boundaries. HAR uploads, error reports, debug dumps — strip Authorization headers, set-cookie, and known token-shaped values before they leave the user's browser.
Attackers credential-stuffed approximately 14,000 accounts using passwords reused from other breaches, then traversed the DNA Relatives social graph to expose data on roughly 6.9 million users.
Lesson: Per-account read quotas on relationship and graph endpoints, not just per-IP rate limits. A single compromised account should not be able to enumerate the social graph faster than a human user reasonably would. Pwned-password screening (HIBP) at registration and password-reset blocks the inbound vector entirely.
NIST AAL terminology in parentheses where relevant.
httpOnly, secure in prod, sameSite=lax or stricteralg=none), signing-key tenancy (Storm-0558)residentKey: 'preferred') for conditional UIid_token audience and issuer validatedtesting
Configure install-time cooldowns for npm/bun (minimum release age) and run a sandboxed pre-install scan when the cooldown has to be bypassed. Use when the user asks about supply-chain attacks, npm/bun security, "minimum release age", a "cooldown" for installs, hardening against Shai-Hulud-class worms, or how to safely install a package that was just published. Also use after any recent supply-chain incident in the npm ecosystem.
tools
Generate CLAUDE.md project memory files that transfer institutional knowledge, not obvious information. Use when setting up new journalism projects, onboarding collaborators, or documenting project-specific quirks. Includes templates for editorial tools, event websites, publications, research projects, content pipelines, and digital archives.
development
Use when suggesting APIs for a project, looking for free data sources, building weekend projects that need external data, or when the user needs weather, news, finance, sports, ML, or entertainment data without paid subscriptions
development
Choose the correct CLAUDE.md or LESSONS.md template for journalism projects. Use when starting a new project, setting up documentation, or unsure which template category fits best. Provides decision trees and selection guidance for 6 journalism-focused template types.