UNPKG

skills-ws

Version:

85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.

899 lines (745 loc) • 40.7 kB
--- name: auth-implementation description: "Secure authentication & authorization — OAuth 2.1/OIDC with PKCE & state, JWT/JWKS verification, hashed-rotating refresh tokens, sessions/BFF, passkeys/WebAuthn, MFA/TOTP, RBAC/ABAC, password hashing, and CSRF. Use when implementing or reviewing auth, authz, MFA, passkeys, OAuth/OIDC, sessions, tokens, or access control." --- # Authentication & Authorization Security-critical patterns for AuthN/AuthZ in 2026. Code here is meant to be copied, so it is written to be correct and safe by default: every secret stored hashed, every token rotation atomic, every redirect-based flow CSRF-protected via `state`/PKCE. Vendor endpoints and library APIs drift — when a value here is dated, the inline note tells you where to re-verify. **Threat-model defaults**: assume the browser is hostile (XSS can read anything JS can), assume tokens leak, assume requests are replayed and races happen. Prefer short-lived access tokens + server-held session/refresh state. For SPAs, prefer a **BFF (Backend-for-Frontend)** holding tokens server-side over putting access tokens in `localStorage`. --- ## 1. OAuth 2.1 / OIDC Flows OAuth 2.1 (the consolidation of 2.0 + best-practice RFCs) makes **PKCE mandatory for all clients**, forbids the implicit and password grants, and requires exact redirect-URI matching. Use the **Authorization Code flow + PKCE everywhere** (yes, even confidential server-side clients). ### Discover endpoints (don't hardcode) Prefer the provider's OIDC discovery document over hardcoded URLs so endpoints and the JWKS URI stay correct: ```javascript // Fetch once at boot, cache in memory (respect Cache-Control) const discovery = await fetch( 'https://accounts.google.com/.well-known/openid-configuration' ).then(r => r.json()); // => { authorization_endpoint, token_endpoint, jwks_uri, issuer, ... } // Google (as of Jun 2026): authorization_endpoint = https://accounts.google.com/o/oauth2/v2/auth // token_endpoint = https://oauth2.googleapis.com/token // Verify: https://accounts.google.com/.well-known/openid-configuration ``` ### Authorization Code + PKCE — full server-side flow with `state` This is the canonical flow. **`state` and the PKCE `code_verifier` are both persisted server-side before redirect and verified on callback** — skipping either reopens CSRF / login-injection / code-injection. Below, secrets live on the server and only `code_challenge` + `state` ever hit the browser. ```javascript import crypto from 'node:crypto'; const b64url = (buf) => buf.toString('base64url'); // Node >=16 supports 'base64url' function pkcePair() { const verifier = b64url(crypto.randomBytes(32)); // 43-128 chars, high entropy const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); return { verifier, challenge }; } // --- Step 1: begin login (server route) --- app.get('/auth/login', async (req, res) => { const state = b64url(crypto.randomBytes(32)); const nonce = b64url(crypto.randomBytes(32)); // OIDC: binds id_token to this session const { verifier, challenge } = pkcePair(); // PERSIST state + verifier + nonce server-side, keyed to THIS session, BEFORE redirecting. // Short TTL; single use. (Express-session shown; a signed httpOnly cookie also works.) req.session.oauth = { state, nonce, verifier, createdAt: Date.now() }; const url = new URL(discovery.authorization_endpoint); url.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID); url.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI); // must EXACTLY match registered URI url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('state', state); url.searchParams.set('nonce', nonce); res.redirect(url.toString()); }); // --- Step 2: callback (server route) — VALIDATE everything --- app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; const saved = req.session.oauth; delete req.session.oauth; // consume immediately so it can't be replayed // (a) provider returned an error? if (req.query.error) return res.status(400).send(`OAuth error: ${req.query.error}`); // (b) we actually started a flow, and it hasn't expired if (!saved || Date.now() - saved.createdAt > 10 * 60 * 1000) { return res.status(400).send('No pending OAuth flow / expired'); } // (c) STATE MUST MATCH — constant-time compare to avoid timing oracles const ok = typeof state === 'string' && state.length === saved.state.length && crypto.timingSafeEqual(Buffer.from(state), Buffer.from(saved.state)); if (!ok) return res.status(403).send('Invalid OAuth state'); // CSRF / login-injection blocked here // (d) need a code if (typeof code !== 'string' || !code) return res.status(400).send('Missing authorization code'); // (e) exchange code — token request is form-encoded; include the PKCE verifier we persisted const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: process.env.OAUTH_REDIRECT_URI, client_id: process.env.OAUTH_CLIENT_ID, code_verifier: saved.verifier, // proves we started this exact flow }); // Confidential clients add their secret (Basic auth header preferred over body params): const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (process.env.OAUTH_CLIENT_SECRET) { const basic = Buffer.from( `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}` ).toString('base64'); headers.Authorization = `Basic ${basic}`; } const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers, body }); if (!tokenRes.ok) return res.status(401).send('Token exchange failed'); const tokens = await tokenRes.json(); // { access_token, refresh_token, id_token, expires_in } // (f) Validate the OIDC id_token signature/iss/aud/exp AND that nonce matches saved.nonce // (see §2 verifyToken; pass audience = OAUTH_CLIENT_ID and check decoded.nonce === saved.nonce) // (g) Establish your OWN session here (don't hand provider tokens to the browser). res.redirect('/'); }); ``` ### Authorization Code + PKCE — public client (SPA/mobile) caveat A SPA cannot keep `state`/`verifier` truly secret from XSS. `sessionStorage` survives a redirect but is JS-readable. **Preferred 2026 pattern: run the code exchange in a BFF** so the browser never holds tokens. If you must do it browser-side, still generate and check `state`, and still send the `code_verifier`: ```javascript // Browser: begin const { verifier, challenge } = await pkcePairWebCrypto(); // Web Crypto version below const state = crypto.randomUUID(); sessionStorage.setItem('pkce_verifier', verifier); sessionStorage.setItem('oauth_state', state); const url = new URL(discovery.authorization_endpoint); url.searchParams.set('client_id', CLIENT_ID); url.searchParams.set('redirect_uri', REDIRECT_URI); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('state', state); location.href = url.toString(); // Browser: callback — VALIDATE state before exchanging const params = new URLSearchParams(location.search); const code = params.get('code'); const returnedState = params.get('state'); const savedState = sessionStorage.getItem('oauth_state'); const verifier = sessionStorage.getItem('pkce_verifier'); sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('pkce_verifier'); if (!code || !returnedState || returnedState !== savedState) { throw new Error('Invalid OAuth state or missing code'); // stop — do not exchange } const res = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: verifier, }), }); const tokens = await res.json(); // Web Crypto PKCE (browser) async function pkcePairWebCrypto() { const bytes = crypto.getRandomValues(new Uint8Array(32)); const verifier = btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); return { verifier, challenge }; } ``` ### Client Credentials Flow (Machine-to-Machine) For backend services and API-to-API calls. No user. Token requests are **form-encoded** per RFC 6749, the secret stays server-side, and you should **cache the token** until shortly before `expires_in` rather than minting one per call. ```javascript let cached = { token: null, exp: 0 }; async function getServiceToken() { if (cached.token && Date.now() < cached.exp - 60_000) return cached.token; // 60s safety margin const res = await fetch(process.env.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.CLIENT_ID, client_secret: process.env.CLIENT_SECRET, // server-only; never ship to a browser audience: 'https://api.example.com', // provider-specific (Auth0 uses `audience`) scope: 'read:things write:things', }), }); if (!res.ok) throw new Error(`Token request failed: ${res.status}`); const { access_token, expires_in } = await res.json(); cached = { token: access_token, exp: Date.now() + expires_in * 1000 }; return access_token; } ``` --- ## 2. JWT (JSON Web Tokens) ### Structure ``` header.payload.signature Header: { "alg": "RS256", "typ": "JWT", "kid": "key-id-1" } Payload: { "iss": "https://auth.example.com", "aud": "https://api.example.com", "sub": "user123", "role": "admin", "iat": 1706000000, "exp": 1706003600 } Signature: RS256(base64url(header) + "." + base64url(payload), privateKey) ``` ### JWT Validation (Node.js) — pin algorithm, issuer, audience The most common JWT bugs: accepting `alg: none`, accepting whatever `alg` the token claims (HS256/RS256 confusion), or not checking `iss`/`aud`. **Always pin `algorithms` explicitly, and always validate `issuer` and `audience`.** Cache JWKS keys (jwks-rsa caches + rate-limits by default). ```javascript import jwt from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; const client = jwksClient({ jwksUri: 'https://auth.example.com/.well-known/jwks.json', cache: true, cacheMaxEntries: 5, cacheMaxAge: 10 * 60 * 1000, // 10 min rateLimit: true, jwksRequestsPerMinute: 10, }); function getKey(header, callback) { client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey())); } function verifyToken(token) { return new Promise((resolve, reject) => { jwt.verify(token, getKey, { algorithms: ['RS256'], // pin; never allow 'none' or HS*/RS* mixing issuer: 'https://auth.example.com', // must match token iss audience: 'https://api.example.com', // must match token aud (your API/client id) clockTolerance: 5, // seconds, for minor clock skew }, (err, decoded) => (err ? reject(err) : resolve(decoded))); }); } // Express middleware async function authMiddleware(req, res, next) { const token = req.headers.authorization?.replace(/^Bearer /, ''); if (!token) return res.status(401).json({ error: 'No token provided' }); try { req.user = await verifyToken(token); next(); } catch { return res.status(401).json({ error: 'Invalid token' }); } } ``` `jsonwebtoken` is the workhorse; for ESM/edge runtimes consider **`jose`** (`jwtVerify` + `createRemoteJWKSet`), which is promise-native and runs on Web Crypto. ### Refresh Token Rotation — hashed, atomic, reuse-detecting Refresh tokens are long-lived bearer credentials, so treat them like passwords: 1. **Store only a hash** (SHA-256 is fine for a high-entropy random token; you don't need bcrypt/argon2 for 256-bit randomness). The raw token exists only in the client's secure cookie. 2. **Look up by hash**, never by raw value. 3. **Rotate atomically** in a DB transaction with a compare-and-set so two concurrent refreshes can't both succeed. 4. **Detect reuse**: a refresh token is single-use. If a *used/rotated* token is presented again, treat it as theft and **revoke the whole token family**. ```javascript import crypto from 'node:crypto'; const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); const newOpaqueToken = () => crypto.randomBytes(32).toString('base64url'); // 256-bit, unguessable // Issue at login: create a family, store HASH, return RAW token to client (httpOnly cookie) async function issueRefreshToken(userId, meta) { const raw = newOpaqueToken(); const familyId = crypto.randomUUID(); await db.refreshToken.create({ data: { tokenHash: sha256(raw), userId, familyId, used: false, revoked: false, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days userAgent: meta?.userAgent, ip: meta?.ip, // device/session metadata }}); return raw; // caller sets it as a Secure; HttpOnly; SameSite cookie (path=/auth/refresh) } app.post('/auth/refresh', async (req, res) => { const raw = req.cookies?.refresh_token; // delivered via httpOnly cookie, not body if (!raw) return res.status(401).json({ error: 'No refresh token' }); const tokenHash = sha256(raw); try { const result = await db.$transaction(async (tx) => { // Lock the row (Postgres) so concurrent refreshes serialize on it. const [stored] = await tx.$queryRaw` SELECT * FROM "RefreshToken" WHERE "tokenHash" = ${tokenHash} FOR UPDATE`; if (!stored) throw { code: 'INVALID' }; // REUSE DETECTION: a used or revoked token presented again => credential theft. if (stored.used || stored.revoked) { await tx.refreshToken.updateMany({ where: { familyId: stored.familyId }, data: { revoked: true }, // nuke the entire family }); throw { code: 'REUSE' }; } if (stored.expiresAt < new Date()) throw { code: 'EXPIRED' }; // Atomic compare-and-set: only the first concurrent caller flips used:false -> true. const claim = await tx.refreshToken.updateMany({ where: { id: stored.id, used: false }, data: { used: true }, }); if (claim.count !== 1) throw { code: 'RACE' }; // someone else won; reject this one // Mint the next token in the SAME family and store its hash. const nextRaw = newOpaqueToken(); await tx.refreshToken.create({ data: { tokenHash: sha256(nextRaw), userId: stored.userId, familyId: stored.familyId, used: false, revoked: false, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), userAgent: req.get('user-agent'), ip: req.ip, }}); const accessToken = jwt.sign( { sub: stored.userId, role: stored.role }, process.env.JWT_PRIVATE_KEY, { algorithm: 'RS256', issuer: 'https://auth.example.com', audience: 'https://api.example.com', expiresIn: '15m' } ); return { accessToken, nextRaw }; }); res.cookie('refresh_token', result.nextRaw, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 30 * 24 * 60 * 60 * 1000, }); res.json({ accessToken: result.accessToken }); } catch (e) { if (e?.code === 'REUSE') { // Optional: log/audit + force user re-auth on all devices. return res.status(401).json({ error: 'Token reuse detected; family revoked' }); } return res.status(401).json({ error: 'Invalid refresh token' }); } }); ``` > The `FOR UPDATE` row lock + `updateMany(where: { used: false })` compare-and-set is what makes this safe under concurrency. On databases without `SELECT ... FOR UPDATE`, rely solely on the conditional update's affected-row count (`claim.count === 1`) as the gate — never on a read-then-write without it. **Token lifetimes:** - Access token: 15 minutes (short-lived, stateless, RS256) - Refresh token: 30 days max, rotated on every use, hashed at rest - ID token: ~1 hour (OIDC user info; validate `nonce` on login) --- ## 3. Session Management ### Cookie-Based Sessions (Traditional / BFF) ```javascript import session from 'express-session'; import { RedisStore } from 'connect-redis'; import { createClient } from 'redis'; const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); app.set('trust proxy', 1); // required behind a TLS-terminating proxy so secure cookies work app.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, // rotate via an array: [newSecret, oldSecret] resave: false, saveUninitialized: false, name: '__Host-session', // __Host- prefix: requires Secure, path=/, no Domain cookie: { secure: true, // HTTPS only httpOnly: true, // no JS access (XSS can't read it) sameSite: 'lax', // mitigates cross-site POST CSRF (not a complete defense — see §10) maxAge: 24 * 60 * 60 * 1000, path: '/', // NOTE: __Host- forbids `domain`. Drop the prefix if you need a shared parent domain. }, })); // Regenerate the session ID on privilege change to prevent session fixation: app.post('/auth/login', loginLimiter, async (req, res) => { // ... verify credentials ... req.session.regenerate((err) => { if (err) return res.status(500).end(); req.session.userId = user.id; res.json({ ok: true }); }); }); ``` ### Cookie vs Token Comparison | Aspect | Cookie Sessions | JWT Access Tokens | |--------|----------------|-------------------| | Storage | Server (Redis/DB) | Client — see XSS row | | Stateless | No (server lookup) | Yes (self-contained) | | Revocation | Easy (delete from store) | Hard (need blocklist or short TTL) | | Scalability | Need shared store | No shared state needed | | XSS exposure | `httpOnly` cookie is unreadable by JS | **Avoid `localStorage`** (JS-readable → XSS steals it). Keep in memory, or use a BFF that holds the token server-side. | | CSRF exposure | Needs CSRF defense (§10) | Safe **only** if sent in `Authorization` header, not a cookie | | Mobile | Needs cookie support | Works everywhere | | Best for | Server-rendered apps, **BFF for SPAs** | Native/mobile, service-to-service | **2026 browser recommendation:** do not store access tokens in `localStorage`/`sessionStorage`. Either (a) use a **BFF** where the browser holds only an `httpOnly` session cookie and the server attaches tokens to upstream calls, or (b) keep the access token in a JS variable in memory and refresh via an `httpOnly` cookie hitting `/auth/refresh`. --- ## 4. Auth.js v5 (NextAuth) Setup — App Router Auth.js v5 splits config into a root `auth.ts` (exports `handlers`, `auth`, `signIn`, `signOut`) and a thin route handler. Provider imports are now `next-auth/providers/*` named like `Google`, `GitHub`, `Credentials` (the old `GoogleProvider` names are v4). Env vars are auto-inferred from `AUTH_<PROVIDER>_ID/SECRET`; set `AUTH_SECRET` (generate with `npx auth secret`) and `AUTH_TRUST_HOST=true` when self-hosting behind a proxy. ```typescript // auth.ts (project root) import NextAuth from 'next-auth'; import Google from 'next-auth/providers/google'; import GitHub from 'next-auth/providers/github'; import Credentials from 'next-auth/providers/credentials'; import { PrismaAdapter } from '@auth/prisma-adapter'; import { prisma } from '@/lib/prisma'; import argon2 from 'argon2'; import { z } from 'zod'; const signInSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), session: { strategy: 'jwt' }, // 'jwt' for Credentials; 'database' for OAuth-only is also fine providers: [ Google, // reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET GitHub, // reads AUTH_GITHUB_ID / AUTH_GITHUB_SECRET Credentials({ credentials: { email: {}, password: {} }, authorize: async (credentials) => { const parsed = signInSchema.safeParse(credentials); if (!parsed.success) return null; const user = await prisma.user.findUnique({ where: { email: parsed.data.email } }); // Same generic outcome whether the user is missing or the password is wrong (no enumeration): if (!user?.hashedPassword) return null; const ok = await argon2.verify(user.hashedPassword, parsed.data.password); return ok ? user : null; }, }), ], callbacks: { async jwt({ token, user }) { if (user) { token.role = user.role; token.id = user.id; } return token; }, async session({ session, token }) { session.user.role = token.role as string; session.user.id = token.id as string; return session; }, }, pages: { signIn: '/login', error: '/auth/error' }, }); ``` ```typescript // app/api/auth/[...nextauth]/route.ts import { handlers } from '@/auth'; export const { GET, POST } = handlers; ``` ```typescript // types: augment the session/JWT so token.role/id type-check // types/next-auth.d.ts import 'next-auth'; declare module 'next-auth' { interface User { role?: string } interface Session { user: { id: string; role?: string } & DefaultSession['user'] } } ``` Read the session in a Server Component/route via `const session = await auth();`. Protect routes in `middleware.ts` by exporting `auth` as the middleware. --- ## 5. Passport.js Strategies `Invalid email` vs `Invalid password` are different responses, which lets an attacker enumerate accounts. **Return one generic message** and run the password compare even when the user is missing (constant-ish work) so timing doesn't leak existence either. ```javascript import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as LocalStrategy } from 'passport-local'; import argon2 from 'argon2'; // A precomputed dummy argon2 hash so we do equivalent work when the user doesn't exist. const DUMMY_HASH = process.env.DUMMY_ARGON2_HASH; // generate once: argon2.hash('not-a-real-password') passport.use(new LocalStrategy( { usernameField: 'email' }, async (email, password, done) => { const user = await db.findUserByEmail(String(email).trim().toLowerCase()); const hash = user?.hashedPassword ?? DUMMY_HASH; const valid = await argon2.verify(hash, password).catch(() => false); if (!user || !valid) { return done(null, false, { message: 'Invalid credentials' }); // generic; no enumeration } return done(null, user); } )); passport.use(new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: '/auth/google/callback', }, async (accessToken, refreshToken, profile, done) => { const email = profile.emails?.[0]?.value; let user = await db.findUserByGoogleId(profile.id); if (!user) { user = await db.createUser({ googleId: profile.id, email, name: profile.displayName }); } return done(null, user); })); passport.serializeUser((user, done) => done(null, user.id)); passport.deserializeUser(async (id, done) => done(null, await db.findUserById(id))); ``` Also add **account lockout / progressive delay** after repeated failures (see §10 rate limiting) and **audit-log** auth events (login success/failure, password change, MFA enrollment) with user id, IP, and timestamp. --- ## 6. Passkeys / WebAuthn (`@simplewebauthn/server` v13) The v13 API differs from older snippets you'll find online: - `generateRegistrationOptions` takes **`userName`/`userDisplayName`** — there is no `userID` option you pass a string to anymore. - After registration, persist **`registrationInfo.credential`** = `{ id, publicKey, counter, transports }` (plus `credentialDeviceType`/`credentialBackedUp`). `id` is a Base64URL string; `publicKey` is a `Uint8Array` (store as bytes/`bytea`). - `verifyAuthenticationResponse` takes a single **`credential`** object (the old `authenticator:` key is gone) and a **`requireUserVerification`** boolean. - Always store and re-send `transports`, and **write back `newCounter`** to detect cloned authenticators. ```javascript import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; const rpName = 'My App'; const rpID = 'example.com'; // domain only, no scheme/port const origin = 'https://example.com'; // full origin the browser sends // --- Registration: options --- app.post('/auth/passkey/register/options', async (req, res) => { const user = req.user; const existing = await db.getCredentialsByUserId(user.id); const options = await generateRegistrationOptions({ rpName, rpID, userName: user.email, userDisplayName: user.name ?? user.email, attestationType: 'none', excludeCredentials: existing.map(c => ({ id: c.credentialId, transports: c.transports })), authenticatorSelection: { residentKey: 'preferred', // 'required' for usernameless/discoverable login userVerification: 'preferred', // 'required' to force biometric/PIN }, supportedAlgorithmIDs: [-7, -257], // ES256, RS256 }); await db.saveChallenge(user.id, options.challenge); // store server-side, short TTL res.json(options); }); // --- Registration: verify --- app.post('/auth/passkey/register/verify', async (req, res) => { const user = req.user; const expectedChallenge = await db.getChallenge(user.id); let verification; try { verification = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); } catch (err) { return res.status(400).json({ error: err.message }); } if (verification.verified && verification.registrationInfo) { const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; await db.saveCredential(user.id, { credentialId: credential.id, // Base64URLString publicKey: credential.publicKey, // Uint8Array -> store as bytes counter: credential.counter, transports: credential.transports, // e.g. ['internal','hybrid'] deviceType: credentialDeviceType, backedUp: credentialBackedUp, }); } await db.clearChallenge(user.id); res.json({ verified: verification.verified }); }); // --- Authentication: options --- app.post('/auth/passkey/login/options', async (req, res) => { // Optionally scope to a known user's credentials; omit allowCredentials for usernameless flow. const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', // allowCredentials: creds.map(c => ({ id: c.credentialId, transports: c.transports })), }); await db.saveSessionChallenge(req.sessionID, options.challenge); res.json(options); }); // --- Authentication: verify --- app.post('/auth/passkey/login/verify', async (req, res) => { const expectedChallenge = await db.getSessionChallenge(req.sessionID); const stored = await db.getCredentialById(req.body.id); // req.body.id is Base64URLString if (!stored) return res.status(400).json({ error: 'Unknown credential' }); let verification; try { verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { // v13 shape (was `authenticator`) id: stored.credentialId, publicKey: stored.publicKey, // Uint8Array counter: stored.counter, transports: stored.transports, }, requireUserVerification: true, }); } catch (err) { return res.status(400).json({ error: err.message }); } if (verification.verified) { // CRITICAL: persist newCounter to detect cloned authenticators / replay. await db.updateCounter(stored.id, verification.authenticationInfo.newCounter); await db.clearSessionChallenge(req.sessionID); req.login(stored.user, () => res.json({ verified: true })); } else { res.status(401).json({ verified: false }); } }); ``` Pair with `@simplewebauthn/browser` (`startRegistration`/`startAuthentication`) on the client. Verify the current API at https://simplewebauthn.dev (this skill targets v13). --- ## 7. RBAC & ABAC ### Role-Based Access Control (RBAC) ```typescript const PERMISSIONS = { admin: ['read', 'write', 'delete', 'manage_users', 'manage_billing'], editor: ['read', 'write'], viewer: ['read'], } as const; type Role = keyof typeof PERMISSIONS; type Permission = (typeof PERMISSIONS)[Role][number]; function requirePermission(permission: Permission) { return (req, res, next) => { const userRole = req.user?.role as Role | undefined; const perms = (userRole && PERMISSIONS[userRole]) || []; if (!perms.includes(permission)) return res.status(403).json({ error: 'Forbidden' }); next(); }; } app.delete('/api/posts/:id', requirePermission('delete'), deletePost); app.get('/api/posts', requirePermission('read'), listPosts); ``` ### Attribute-Based Access Control (ABAC) ```typescript interface PolicyContext { user: { id: string; role: string; department: string }; resource: { ownerId: string; type: string; status: string; department?: string }; action: string; } function evaluatePolicy(ctx: PolicyContext): boolean { if (ctx.user.role === 'admin') return true; // Owners can edit their own resources if (ctx.action === 'edit' && ctx.resource.ownerId === ctx.user.id) return true; // Editors can edit published resources in their own department if (ctx.action === 'edit' && ctx.user.role === 'editor' && ctx.resource.status === 'published' && ctx.resource.department === ctx.user.department) return true; return false; // default-deny } ``` **Default-deny** is the rule: if no policy explicitly allows the action, reject. Always enforce authorization **server-side per request** — never trust a client-sent role/permission, and re-check ownership on every mutating route (most IDOR bugs are a missing per-object check). --- ## 8. Password Hashing Use a memory-hard algorithm. **`argon2id` is the OWASP first choice**; `bcrypt` is an acceptable, widely-supported fallback (note bcrypt silently truncates inputs beyond 72 bytes — pre-hash with SHA-256 if you must allow long passphrases). Never roll your own. ```javascript import argon2 from 'argon2'; import bcrypt from 'bcryptjs'; // argon2id (recommended) — OWASP 2026 baseline params const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB (OWASP min); raise to 46–64 MiB on capable servers timeCost: 2, // iterations parallelism: 1, }); const valid = await argon2.verify(hash, password); // bcrypt fallback (cost >=12 in 2026) const bhash = await bcrypt.hash(password, 12); const bvalid = await bcrypt.compare(password, bhash); ``` **Never:** MD5, SHA-1, plain SHA-256/512 (fast → brute-forceable), or any unsalted/un-stretched hash for passwords. Check new passwords against a breached-password list (e.g., HIBP k-anonymity range API) and enforce a sane minimum length over arbitrary complexity rules (NIST SP 800-63B). --- ## 9. MFA / 2FA with TOTP Three correctness rules this implements: 1. **Never return the TOTP secret to the client as a "backup code."** The secret is the authenticator seed; if it's also a "backup code" then anyone who saw setup can mint valid TOTP codes forever. Backup codes are **separate, random, single-use, stored hashed**. 2. **Verify enrollment before activating**, and tolerate one time-step of clock drift (`window: 1`). 3. **Bind the login MFA step to a pending first-factor session** — never trust a `userId` from the request body, or anyone can complete MFA "as" any user id. ```javascript import { authenticator } from 'otplib'; import QRCode from 'qrcode'; import crypto from 'node:crypto'; const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); // --- Setup: generate secret + QR. Do NOT return the secret as a backup code. --- app.post('/auth/mfa/setup', requireAuth, async (req, res) => { const secret = authenticator.generateSecret(); const otpauth = authenticator.keyuri(req.user.email, 'MyApp', secret); const qrCode = await QRCode.toDataURL(otpauth); await db.saveTempMfaSecret(req.user.id, secret); // pending; not yet active // Return ONLY the QR/otpauth so the user can scan it. The raw secret is shown once for // manual entry only if you choose to; it is NOT a backup code. res.json({ qrCode, otpauth }); }); // --- Verify enrollment + issue SEPARATE single-use backup codes (stored hashed) --- app.post('/auth/mfa/verify', requireAuth, async (req, res) => { const { token } = req.body; const secret = await db.getTempMfaSecret(req.user.id); if (!secret || !authenticator.verify({ token, secret, window: 1 })) { return res.status(400).json({ error: 'Invalid code' }); } await db.activateMfa(req.user.id, secret); // Backup codes: random, single-use, displayed ONCE, stored only as hashes. const plain = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex')); // 10×10-hex await db.replaceBackupCodes(req.user.id, plain.map(c => sha256(c))); // store HASHES only res.json({ success: true, backupCodes: plain }); // show once; never retrievable again }); // --- Login MFA challenge: bind to a pending first-factor session, NOT a body userId --- app.post('/auth/mfa/challenge', async (req, res) => { const { token } = req.body; // mfaPending was set by the password/first-factor step after it verified credentials. const userId = req.session?.mfaPending?.userId; if (!userId) return res.status(401).json({ error: 'No pending login' }); const user = await db.findUserById(userId); let ok = authenticator.verify({ token, secret: user.mfaSecret, window: 1 }); if (!ok) { // Backup code path: look up by HASH and consume single-use. ok = await db.consumeBackupCode(userId, sha256(String(token))); } if (!ok) return res.status(401).json({ error: 'Invalid MFA code' }); // First + second factor both satisfied → now establish the authenticated session. delete req.session.mfaPending; req.session.regenerate((err) => { if (err) return res.status(500).end(); req.session.userId = userId; res.json({ accessToken: issueAccessToken(user) }); }); }); // The first-factor handler that sets mfaPending (sketch): // after verifying email+password, if user.mfaEnabled: // req.session.mfaPending = { userId: user.id, at: Date.now() }; // short TTL // return res.json({ mfaRequired: true }); ``` Rate-limit `/auth/mfa/challenge` per pending session (e.g., 5 attempts) and expire `mfaPending` after a few minutes. For recovery, require re-verification (email link + cooldown) before disabling MFA, and re-issue fresh backup codes when the user regenerates them. --- ## 10. Security Best Practices ### CSRF Protection (do NOT use `csurf`) `csurf` has been **deprecated/unmaintained since 2022** — don't add it to new code. Use SameSite cookies as a baseline plus an explicit token defense, and validate `Origin`/`Referer` on state-changing requests. ```javascript // Option A (recommended for sessions): double-submit signed token via `csrf-csrf` import { doubleCsrf } from 'csrf-csrf'; const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET, getSessionIdentifier: (req) => req.session?.id ?? '', // REQUIRED: binds the token to this session cookieName: '__Host-csrf', cookieOptions: { sameSite: 'strict', secure: true, path: '/', httpOnly: true }, getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'], }); app.get('/csrf-token', (req, res) => res.json({ csrfToken: generateCsrfToken(req, res) })); app.use(doubleCsrfProtection); // rejects unsafe methods without a matching token // Option B (defense in depth): reject state-changing requests from foreign origins app.use((req, res, next) => { if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { const origin = req.get('origin') || req.get('referer') || ''; const allowed = ['https://app.example.com']; if (!allowed.some(a => origin.startsWith(a))) { return res.status(403).json({ error: 'Cross-origin request blocked' }); } } next(); }); ``` For SPAs/mobile using JWT in the `Authorization` header (not cookies), CSRF tokens aren't required — the browser won't auto-attach a header cross-site. **But** if you store any auth state in cookies (including a BFF session), you DO need CSRF defense. `SameSite=Lax/Strict` reduces risk but is not complete: it doesn't cover same-site subdomain attacks, and `Lax` still allows top-level cross-site GETs — so never perform state changes on GET. ### Secure Cookie Configuration ```javascript res.cookie('__Host-session', token, { httpOnly: true, // JS can't read it (XSS mitigation) secure: true, // HTTPS only sameSite: 'strict', // 'lax' only if you need top-level cross-site navigation to stay logged in maxAge: 86_400_000, // 24h path: '/', // __Host- prefix => browser enforces Secure + path=/ + NO Domain (locks cookie to exact host) }); ``` ### Rate Limiting Login Attempts Throttle on **IP and account, normalized**, not just one. The default IP key generator mishandles IPv6 (a whole /64 shares an address) — use `express-rate-limit`'s `ipKeyGenerator` helper. Combine a coarse per-IP limit with a stricter per-account limit, and add account lockout/backoff for repeated failures. ```javascript import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // 10 attempts per key per window standardHeaders: 'draft-7', legacyHeaders: false, message: { error: 'Too many login attempts. Try again later.' }, // Key by account + IP. Normalize email; ipKeyGenerator() handles IPv6 correctly. keyGenerator: (req) => { const email = String(req.body?.email ?? '').trim().toLowerCase(); return `${email}|${ipKeyGenerator(req.ip)}`; }, }); app.post('/auth/login', loginLimiter, loginHandler); ``` For distributed deployments back the limiter with a shared store (e.g. `rate-limit-redis`) so limits are global, not per-instance. Track consecutive failures per account and apply exponential backoff or temporary lockout, with an audit log entry per failure. ### Social Login Setup (Google, GitHub, Apple) **Required env vars per provider** (verify scopes/console layout at each link; layouts change): | Provider | Vars | Console | |----------|------|---------| | Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | console.cloud.google.com → APIs & Services → Credentials | | GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | github.com/settings/developers → OAuth Apps | | Apple | `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | developer.apple.com → Certificates, IDs & Profiles | (With Auth.js v5, prefer the auto-inferred `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` form.) **Callback URLs:** register the exact callback URL(s); no wildcards in production. Keep all client secrets and Apple private keys server-side only — never expose them to the browser bundle. ### Secure logout / session invalidation - Server sessions: destroy the session record (`req.session.destroy`) and clear the cookie. Don't rely on the client to "forget" the cookie. - JWTs: keep them short-lived; on logout, delete/revoke the refresh-token family (§2) and, for high-value apps, add the access token's `jti` to a short-TTL blocklist until it expires. - On password change or detected compromise, revoke **all** of the user's refresh-token families and active sessions.