Web Ssrf
s0ld13rr/pentestcode
Server-Side Request Forgery detection→internal-access→proof for web apps.
Secure authentication patterns (OWASP, NIST). An agent skill from jamditis/claude-skills-journalism.
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install jamditis/claude-skills-journalism secure-auth --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .claude/skills && cp -r skills-src/security-toolkit/skills/secure-auth .claude/skills/secure-auth && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .claude/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-authType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install jamditis/claude-skills-journalism secure-auth --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .agents/skills && cp -r skills-src/security-toolkit/skills/secure-auth .agents/skills/secure-auth && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .agents/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install jamditis/claude-skills-journalism secure-auth --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/security-toolkit/skills/secure-auth .cursor/skills/secure-auth && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .cursor/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/jamditis/claude-skills-journalism.git --path security-toolkit/skills/secure-auth--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install jamditis/claude-skills-journalism secure-auth --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/security-toolkit/skills/secure-auth .gemini/skills/secure-auth && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .gemini/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install jamditis/claude-skills-journalism secure-authInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .github/skills && cp -r skills-src/security-toolkit/skills/secure-auth .github/skills/secure-auth && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .github/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add jamditis/claude-skills-journalism --skill secure-auth -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install jamditis/claude-skills-journalism secure-auth --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/jamditis/claude-skills-journalism.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/security-toolkit/skills/secure-auth .opencode/skills/secure-auth && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "secure-auth" agent skill from https://github.com/jamditis/claude-skills-journalism/tree/master/security-toolkit/skills/secure-auth into .opencode/skills/secure-auth/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "secure-auth", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
secure-authSecure authentication patterns (OWASP, NIST). An agent skill from jamditis/claude-skills-journalism.
Secure Auth is an agent skill from jamditis/claude-skills-journalism. Secure authentication patterns (OWASP, NIST). Use for login, registration, password reset, sessions, JWT, OAuth, MFA, passkeys.
Its SKILL.md is about 14k tokens, which your agent loads only when the skill is triggered. The skill folder holds 2 other files (for example `agents/openai.yaml`).
It sits in Backend & APIs, covering Authentication, Web application vulnerabilities and OAuth and OpenID Connect. The repository describes itself as: Claude Code skills for journalism, media, and academia - verification, FOIA, data journalism, academic writing, and more. The licence is MIT.
Read from SKILL.md and the folder at commit e3e2172. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
npmnpxpipFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
api.pwnedpasswords.comaccounts.google.comAlso links to:
datatracker.ietf.orgcisa.govw3.orgcheatsheetseries.owasp.orgcsrc.nist.govhaveibeenpwned.comunitedhealthgroup.comcloud.google.comdocs.snowflake.comsec.okta.comFrom URLs in SKILL.md, links to its own repository left out.
Names these keys or tokens, usually read from environment variables:
REFRESH_TOKEN_SECRETACCESS_TOKEN_SECRETSESSION_SECRETGOOGLE_CLIENT_SECRETFrom names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Secure Auth loads about 14k tokens when it runs. Until then it costs about 35 tokens; SKILL.md has 3,041 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from jamditis/claude-skills-journalism at commit e3e2172, republished under its MIT licence (© jamditis). 3,041 words, ~13,782 tokens.
.claude/skills/secure-auth/SKILL.md (or your agent's skills folder). This skill also uses 1 other file; get the full folder from GitHub.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
passRFC 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 });
});Install and bundle the browser helper locally. Commit the lockfile and generated
asset, and verify its checksum during deployment. If the module code remains
inline as in the examples below, generate a fresh nonce for every response and
send a CSP such as script-src 'self' 'nonce-{{CSP_NONCE}}'; replace the
placeholder in both the header and tag, and never reuse a nonce. Alternatively,
move the module code into a same-origin external file and use script-src 'self' without a nonce. A remote module graph cannot be secured by adding SRI
to only its first import.
npm install --save-exact @simplewebauthn/browser@13.3.0
npm install --save-dev --save-exact esbuild@0.28.1
npm ci
mkdir -p public/vendor
npx esbuild @simplewebauthn/browser --bundle --format=esm --platform=browser \
--outfile=public/vendor/simplewebauthn-browser-13.3.0.mjs
sha256sum public/vendor/simplewebauthn-browser-13.3.0.mjs \
> public/vendor/SHA256SUMS
sha256sum -c public/vendor/SHA256SUMS<!-- Browser, local @simplewebauthn/browser bundle -->
<script type="module" nonce="{{CSP_NONCE}}">
import { startRegistration }
from '/vendor/simplewebauthn-browser-13.3.0.mjs';
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" nonce="{{CSP_NONCE}}">
import { startAuthentication, browserSupportsWebAuthnAutofill }
from '/vendor/simplewebauthn-browser-13.3.0.mjs';
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 validated© jamditis, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 1 other file in security-toolkit/skills/secure-auth of jamditis/claude-skills-journalism.
Open the folder on GitHubat commit e3e2172
Secure Auth next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Secure Auth this skilljamditis/claude-skills-journalism | 416 | — | ~14k | Automated safety check: Pass | MIT | |
| Web Ssrfs0ld13rr/pentestcode | 817 | — | ~660 | Automated safety check: Warn | MIT | |
| API Security EngineerFerroxLabs/wayland | 608 | — | ~3.1k | Automated safety check: Pass | Apache-2.0 | |
| Supercheck Security Authsupercheck-io/supercheck | 215 | — | ~1.2k | Automated safety check: Pass | AGPL-3.0 | |
| Frappe Errors APIImpertio-Studio/Frappe_Claude_Skill_Package | 187 | 1 repos | ~4k | Automated safety check: Pass | MIT | |
| Hunt Atoelementalsouls/Claude-BugHunter | 4.8k | — | ~3.4k | Automated safety check: Pass | MIT |
s0ld13rr/pentestcode
Server-Side Request Forgery detection→internal-access→proof for web apps.
FerroxLabs/wayland
API security expertise covering OWASP API Security Top 10, API authentication and authorization patterns, API key management, rate limiting and throttling, JWT security best practices, OAuth 2.0…
supercheck-io/supercheck
Work on Supercheck authentication, RBAC, tenant isolation, sessions, API and trigger keys, invitations, project membership, project variables, OAuth, super-admin behavior, SSRF, or…
Impertio-Studio/Frappe_Claude_Skill_Package
A skill your agent uses when debugging or handling API errors in Frappe/ERPNext v14/v15/v16.
elementalsouls/Claude-BugHunter
Hunt account takeover taxonomy — 9 distinct paths to ATO, plus chains.
affaan-m/ECC
Buenas prácticas de seguridad en Quarkus para autenticación, autorización, JWT/OIDC, RBAC, validación de entrada, CSRF, gestión de secretos y seguridad de dependencias.
jamditis/claude-skills-journalism
A skill your agent uses when creating distinct website directions, a client review picker, asset catalog, previews, and Cloudflare-ready handoffs.
jamditis/claude-skills-journalism
Builds an Open Knowledge Format (OKF) knowledge base from existing docs, notes, or a repo.
jamditis/claude-skills-journalism
Local Gitleaks scans for staged changes, push ranges, and full history in private repos, with redacted reports.
jamditis/claude-skills-journalism
Acquire, clean, analyze, verify, visualize, and explain data for journalism.
jamditis/claude-skills-journalism
Creates print-ready HTML that exports to PDF. An agent skill from jamditis/claude-skills-journalism.
jamditis/claude-skills-journalism
Establishes how to find and use skills, requiring Skill tool invocation before any response.
Categories
Secure authentication patterns (OWASP, NIST). An agent skill from jamditis/claude-skills-journalism. Secure Auth is an agent skill from jamditis/claude-skills-journalism. Secure authentication patterns (OWASP, NIST).
Secure Auth fits situations like: tasks that involve Authentication; tasks that involve Web application vulnerabilities; tasks that involve OAuth and OpenID Connect.
Run `npx skills add jamditis/claude-skills-journalism --skill secure-auth -a claude-code`. Or copy the skill folder (security-toolkit/skills/secure-auth in jamditis/claude-skills-journalism) into .claude/skills/secure-auth in your project. Claude Code loads it when a task matches its description.
Run `npx skills add jamditis/claude-skills-journalism --skill secure-auth -a codex`. Or copy the skill folder (security-toolkit/skills/secure-auth in jamditis/claude-skills-journalism) into .agents/skills/secure-auth in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add jamditis/claude-skills-journalism --skill secure-auth -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/secure-auth, .gemini/skills/secure-auth, .github/skills/secure-auth and .opencode/skills/secure-auth in your project.
Going by SKILL.md and its folder, Secure Auth needs the command-line tools its instructions call (npm, npx and pip) and credentials named REFRESH_TOKEN_SECRET, ACCESS_TOKEN_SECRET, SESSION_SECRET and GOOGLE_CLIENT_SECRET. Our summary lists: Python 3.
SKILL.md names 12 domains. In commands or code: api.pwnedpasswords.com and accounts.google.com; the agent is likely to contact these when it follows the instructions. As links in the text: datatracker.ietf.org, cisa.gov, w3.org, cheatsheetseries.owasp.org, csrc.nist.gov, haveibeenpwned.com, unitedhealthgroup.com, cloud.google.com, docs.snowflake.com and sec.okta.com. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Secure Auth is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 14k tokens (SKILL.md is roughly 55k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Secure Auth: Web Ssrf (s0ld13rr/pentestcode, 817 stars), API Security Engineer (FerroxLabs/wayland, 608 stars), Supercheck Security Auth (supercheck-io/supercheck, 215 stars) and Frappe Errors API (Impertio-Studio/Frappe_Claude_Skill_Package, 187 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
jamditis (a GitHub user) maintains it in jamditis/claude-skills-journalism, which has 416 GitHub stars. The repository holds 53 skills in this directory. The repository was last updated on October 4, 2026.
Source: jamditis/claude-skills-journalism on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.