| name | secure-auth |
| description | 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. |
Secure authentication
Step 0: Research the current security landscape (do this first)
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.
Default-on, with a documented skip
Run the 4-angle research below by default. Skip ONLY when ALL of these hold:
- (a) You ran this same skill on this same primitive within the last 4 hours of the current session,
- (b) That prior research surfaced no urgent advisories for the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys),
- (c) You log a one-line
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.
Fan out 4 subagents in parallel
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.
Synthesize before applying recipes
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:
- The current normative ceiling (what specs say SHOULD be the default in 2026).
- 1-2 active threats specific to the authentication primitive being implemented (passwords, sessions, JWT, OAuth, MFA, passkeys) from the last 6-12 months.
- Any tooling drift (deprecated lib, new default in a framework, package merged or replaced).
- Any practitioner consensus shift visible in recent cheat sheet / blog updates.
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.
When you cannot run subagents
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.
Authentication architecture decision
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.
Sessions
Use sessions when:
- Server-rendered application
- Need immediate logout / revocation
- Single domain
- Simpler to implement correctly
JWTs (with refresh tokens)
Use JWTs when:
- Multiple services need to verify auth
- Stateless verification preferred (with revocation strategy)
- Mobile app plus API
- Third-party integrations
- High-value APIs benefit from sender-constrained tokens (DPoP per RFC 9449, mTLS per RFC 8705)
Passkeys-first
Use passkeys (WebAuthn / FIDO2) as the primary factor when:
- The user agent supports WebAuthn (every current Chromium, Firefox, Safari, and major mobile browser does)
- You can run alongside passwords during transition (offer passkey enrollment after first login, keep password as fallback while user installs)
- Phishing resistance is required (NIST AAL3, government, financial, medical)
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.
Password storage
The single source of truth for password hashing across this skill. The Session and JWT examples below assume these defaults.
Default: argon2id
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:
- m=47104 KiB (46 MiB), t=1, p=1
- m=19456 KiB (19 MiB), t=2, p=1
- m=12288 KiB (12 MiB), t=3, p=1
- m=9216 KiB (9 MiB), t=4, p=1
- m=7168 KiB (7 MiB), t=5, p=1
const argon2 = require('argon2');
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1
});
const valid = await argon2.verify(hash, password);
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
memory_cost=19456,
time_cost=2,
parallelism=1,
)
hashed = ph.hash(password)
try:
ph.verify(hashed, password)
except VerifyMismatchError:
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.
Alternate: bcrypt
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.
const bcrypt = require('bcrypt');
const crypto = require('crypto');
function safeBcryptInput(password) {
const bytes = Buffer.byteLength(password, 'utf8');
if (bytes > 72) {
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);
Alternate: scrypt
Acceptable. OWASP minimum is N=2^17, r=8, p=1.
PBKDF2: only when FIPS-140 required
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.
Never
- The OS shell-execution primitive composing a password into a command line — pass via stdin or argv.
- Plain-text storage. No exceptions, no "just for now," no "we'll fix it before launch."
- Logging the plain-text password in any code path, including error handlers.
- A custom hashing scheme. Roll-your-own is the most common breach precondition.
Password policy (NIST SP 800-63B-4)
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.
Length
- Single-factor passwords: 15-character minimum.
- Multi-factor passwords (one factor among several): 8-character minimum.
- Maximum: at least 64 characters (verifier MUST allow up to 64; it MAY allow longer).
Composition
- No composition rules. NIST 800-63B-4 §5.1.1 explicitly: "Verifiers and CSPs SHALL NOT impose other composition rules" (no "must contain uppercase / digit / symbol").
- Allow Unicode. Each Unicode code point counts as one character.
- Allow paste. Password managers depend on it.
Rotation
- No periodic rotation. Don't force "change every 90 days." Rotate only on evidence of compromise.
Blocklist (mandatory in 2026)
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.
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) {
return false;
}
const body = await res.text();
return body.split('\n').some(line => line.startsWith(suffix));
}
Phishing resistance
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.
Session-based authentication
Complete Express.js implementation
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();
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sessionId',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
}
}));
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;
}
if (attempts.count >= 5) {
return false;
}
attempts.count++;
loginAttempts.set(ip, attempts);
return true;
}
const ARGON2_OPTS = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1
};
let DUMMY_VERIFY_HASH = null;
(async () => {
DUMMY_VERIFY_HASH = await argon2.hash('argon2-timing-defense-init', ARGON2_OPTS);
})();
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
if (password.length < 15) {
return res.status(400).json({ error: 'Password must be at least 15 characters' });
}
if (await isPwned(password)) {
return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
}
const existingUser = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
return res.status(400).json({ error: 'Registration failed' });
}
const hashedPassword = await argon2.hash(password, ARGON2_OPTS);
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email.toLowerCase(), hashedPassword]
);
req.session.userId = result.rows[0].id;
req.session.createdAt = Date.now();
res.json({ success: true });
});
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const clientIp = req.ip;
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const result = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
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];
const isValid = await argon2.verify(user.password_hash, password).catch(() => false);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
loginAttempts.delete(clientIp);
res.json({ success: true });
});
});
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 });
});
});
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const maxAge = 24 * 60 * 60 * 1000;
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
next();
}
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]);
});
JWT authentication
Full token validation: the Storm-0558 lesson
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 string
aud (audience) — MUST include your service identifier
exp, nbf (expiration / not-before) — MUST be enforced; reject expired or future-dated tokens
scope — MUST contain the scope required for the endpoint
- Signing key — MUST belong to the issuer's tenancy, not "any key the JWKS endpoint hands out"
Complete implementation with refresh tokens
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
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';
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 };
}
function revokeFamily(familyId) {
for (const [id, entry] of refreshTokens) {
if (entry.familyId === familyId) {
entry.revoked = true;
}
}
}
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const accessToken = generateAccessToken(user.id, ['read', 'write']);
const { token: refreshToken } = generateRefreshToken(user.id);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
});
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' });
}
if (stored.used) {
revokeFamily(decoded.familyId);
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
stored.used = true;
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 });
});
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) {
}
}
res.clearCookie('refreshToken');
res.json({ success: true });
});
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']
});
} 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();
};
}
Bound tokens for high-value APIs
Bearer tokens are stealable. For payments, healthcare, government, or any context where token theft is catastrophic, use sender-constrained tokens:
Both make stolen tokens worthless to the attacker.
Frontend token handling
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',
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 (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
await this.refreshToken();
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.
Passkeys / WebAuthn
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:
- Node:
@simplewebauthn/server (current 13.3.0). Companion browser package: @simplewebauthn/browser.
- Python:
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.
Registration ceremony
const {
generateRegistrationOptions,
verifyRegistrationResponse,
} = require('@simplewebauthn/server');
const RP_ID = 'yourapp.com';
const RP_NAME = 'YourApp';
const ORIGIN = 'https://yourapp.com';
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',
userVerification: 'preferred',
},
});
await redisClient.setEx(
`webauthn:reg:${req.userId}`,
300,
options.challenge
);
res.json(options);
});
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 });
});
<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>
Authentication ceremony (with conditional UI)
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.
const {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
app.post('/auth/passkey/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
});
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' });
}
await db.query(
`UPDATE passkeys SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`,
[verification.authenticationInfo.newCounter, credentialId]
);
req.session.regenerate(() => {
req.session.userId = stored.rows[0].user_id;
res.json({ verified: true });
});
});
<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,
});
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>
WebAuthn L3 signal methods
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.
When to use webauthn (Python)
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,
),
)
Password reset flow
Secure implementation
const crypto = require('crypto');
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
res.json({ message: 'If an account exists, a reset link has been sent.' });
const result = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return;
}
const user = result.rows[0];
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 3600000);
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, 'Password Reset', `Reset your password: ${resetUrl}`);
});
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' });
}
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.' });
}
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
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;
const hashedPassword = await argon2.hash(newPassword, ARGON2_OPTS);
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]);
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
res.json({ success: true });
});
OAuth 2.1 (with Google example)
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.
What 2.1 requires (and removes)
- PKCE mandatory for ALL clients, including confidential clients. S256 only —
plain is deprecated.
- Exact-string redirect-URI matching. No prefix or wildcard matching.
- No implicit grant (
response_type=token). Use authorization code with PKCE.
- No Resource Owner Password Credentials (ROPC). First-party login flows go through the authorization endpoint.
- Bearer tokens never in URL query strings. Always Authorization header.
Server-side code-with-PKCE flow (Google)
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 };
}
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);
});
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
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 {
const { tokens } = await oauth2Client.getToken({
code,
codeVerifier: verifier,
});
oauth2Client.setCredentials(tokens);
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;
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]
);
}
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.
MFA (passkey-first)
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:
- Phishing-resistant tier: FIDO2 / WebAuthn (passkeys), PKI smartcards (PIV/CAC). Required at NIST AAL3.
- AAL2-acceptable, not phishing-resistant: App-based push with number matching, TOTP via authenticator app.
- Restricted, last resort: SMS / voice OTP. NIST 800-63B-4 §5 explicitly restricts these.
In 2026: don't add SMS as a new factor. Migrate users off it where you can.
Passkey-as-second-factor
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.
TOTP (still valid for AAL2)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
app.post('/auth/mfa/enable', requireAuth(), async (req, res) => {
const secret = speakeasy.generateSecret({
name: `YourApp:${req.user.email}`,
issuer: 'YourApp'
});
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[encrypt(secret.base32), req.userId]
);
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32,
qrCode: qrCode
});
});
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
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code' });
}
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 });
});
app.post('/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
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' });
}
}
});
SMS: don't add it as a new factor in 2026
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.
Common breach lessons
Vignettes anchoring patterns to actual incidents. Each is a 2-3 sentence summary plus the lesson encoded in the recipes above.
Change Healthcare ransomware (Feb 2024)
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.
Snowflake / UNC5537 (Apr-Jun 2024)
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.
Storm-0558 (CSRB review, 2024-04)
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.
Okta HAR breach (Oct 2023)
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.
23andMe credential stuffing (Oct 2023, settled 2024)
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.
Security considerations checklist
NIST AAL terminology in parentheses where relevant.
Password storage
Password policy (NIST SP 800-63B-4)
Session management
JWT security
Passkeys / WebAuthn
MFA (CISA tier ranks)
OAuth 2.1
Rate limiting
CSRF protection
Information disclosure