Authentication and authorization patterns including OAuth2, JWT, RBAC/ABAC, session management, API keys, password hashing, and MFA. Use for login flows, access control, identity management, tokens, permissions, and API key authentication. Do not use for vulnerability scanning (use loom-security-scan), audits (loom-security-audit), or threat modeling (loom-threat-model).
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Authentication and authorization patterns including OAuth2, JWT, RBAC/ABAC, session management, API keys, password hashing, and MFA. Use for login flows, access control, identity management, tokens, permissions, and API key authentication. Do not use for vulnerability scanning (use loom-security-scan), audits (loom-security-audit), or threat modeling (loom-threat-model).
Authentication proves who you are; authorization decides what you may touch. Authorization is where real bugs cluster — broken object-level authorization (IDOR/BOLA) is the #1 API risk (OWASP API Security Top 10). Get object-ownership checks right before polishing token plumbing.
When to Use
Auth is security-critical; default to Opus (loom-senior-software-engineer) for design, token/session lifecycle, access-control model choice, and anything touching production credentials. Delegate to Sonnet only for well-scoped execution against an existing pattern: unit tests for auth code, boilerplate middleware, scaffolding from a concrete plan. Never ship auth code that a senior hasn't adversarially reviewed against the checklist below.
Scope boundary: this skill is mechanisms. For vuln scanning use loom-security-scan; deep audit loom-security-audit; architecture threats loom-threat-model.
Authorization: IDOR / BOLA (read this first)
Every request that names an object (/orders/123, ?user_id=…, a foreign key in a body) must verify the caller owns or is granted that specific object — authentication ("is logged in") is not authorization. The bug is invisible in tests that only use one account.
// ❌ BOLA: any authenticated user reads any order by guessing the id
app.get("/orders/:id", requireAuth, async (req, res) => {
const order = await db.orders.findUnique({ where: { id: req.params.id } });
res.json(order);
});
// ✅ Scope the query by the authenticated principal (and tenant)
app.get("/orders/:id", requireAuth, async (req, res) => {
const order = await db.orders.findFirst({
where: { id: req.params.id, userId: req.user.id }, // ownership in the WHERE
});
if (!order) return res.status(404).end(); // 404 not 403: don't confirm existence
res.json(order);
});
Rules: enforce ownership/tenant in the query filter, not a post-fetch if; check on writes and nested/batch operations too; never trust an id/role/tenant from the client body (mass-assignment → privilege escalation); default-deny. In multi-tenant systems scope every query by tenant_id — a missing tenant filter is cross-tenant data leakage.
OAuth2 Flows
Flow
Use for
Notes
Authorization Code + PKCE
Server apps AND SPAs/mobile
PKCE now recommended for all clients, not just public ones
Client Credentials
Service-to-service
No user context; scope tightly
Device Code
TVs/CLI with no browser
—
Implicit, Password (ROPC)
—
Deprecated by OAuth 2.1; do not use
// Authorization Code + PKCE
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI); // MUST match a registered exact URI
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", generateSecureState()); // CSRF: verify on callback
authUrl.searchParams.set("code_challenge", challenge); // SHA-256(verifier), base64url
authUrl.searchParams.set("code_challenge_method", "S256"); // never "plain"// token exchange (server-side) includes: grant_type=authorization_code, code, code_verifier
⚠ Gotchas: validate state on the callback (CSRF); validate redirect_uri against an exact allowlist (open-redirect / code interception); code_challenge_method=S256 only; keep client_secret server-side; for OIDC validate the ID token's nonce.
alg: none — a token with no signature. Mitigation: pass an explicit algorithms allowlist to verify; never let the library pick from the header.
RS256 → HS256 key confusion — attacker flips alg to HS256 and signs with your public key (which HS256 treats as the HMAC secret). Mitigation: pin algorithms: ["RS256"]; never accept the token's declared alg.
Missing claim validation — always verify exp, nbf, iss, and aud. A valid signature only proves who issued it, not for whom — without aud a token minted for service A is replayable at service B.
No revocation — JWTs are valid until exp. For logout/ban, keep access tokens short (≤15 min) and gate refresh through a server-side allowlist (below), or maintain a denylist of jti.
Sensitive data in payload — JWT is signed, not encrypted; base64 is readable. No PII/secrets in claims.
HS256 in distributed systems — every verifier needs the shared secret, so any one service can mint tokens. Prefer RS256/ES256 (asymmetric) so verifiers hold only the public key.
Refresh token rotation (enables revocation)
asyncfunctionrefreshTokens(refreshToken) {
const stored = await db.refreshTokens.findUnique({ where: { token: hashToken(refreshToken) } });
if (!stored || stored.expiresAt < newDate()) thrownewUnauthorizedError();
// Reuse detection: if a rotated (deleted) token is presented again, treat as theft →// revoke the whole token family for that user.await db.refreshTokens.delete({ where: { id: stored.id } }); // one-time useconst next = generateSecureToken();
await db.refreshTokens.create({ data: { token: hashToken(next), userId: stored.userId, expiresAt: addDays(newDate(), 7) } });
return { accessToken: signToken({ sub: stored.userId }), refreshToken: next };
}
Store refresh tokens hashed (they are password-equivalent); short access (≤15 min), refresh ≤7 days; deliver refresh tokens in httpOnly; Secure; SameSite cookies, never in JS-readable storage.
RBAC / ABAC
RBAC = permissions grouped into roles (simple, coarse). ABAC = policy functions over subject/resource/action/environment attributes (fine-grained, dynamic — needed for ownership, department, time-of-day, tenant).
// RBAC middleware — expand roles to permissions, honor ":own" resource scopefunctionrequirePermission(permission) {
return(req, res, next) => {
const perms = req.user.roles.flatMap((r) =>ROLES[r]);
const [resource, action, scope] = permission.split(":");
const ok = perms.includes(permission) ||
(scope === "own" && perms.includes(`${resource}:${action}`));
if (!ok) return res.status(403).json({ error: "Forbidden" });
next(); // NOTE: ":own" still requires a per-object ownership check (see IDOR/BOLA)
};
}
⚠ Middleware-level RBAC checks the endpoint; it does not prove the caller owns the specific object the route resolves to. Function-level auth without object-level auth is still BOLA.
Sessions
asyncfunctioncreateSession(userId, req) {
const sessionId = crypto.randomUUID(); // ≥128 bits entropy from a CSPRNGawait redis.setex(`session:${sessionId}`, SESSION_TTL,
JSON.stringify({ userId, createdAt: Date.now(), ip: req.ip, ua: req.headers["user-agent"] }));
await redis.sadd(`user-sessions:${userId}`, sessionId); // index for bulk invalidationreturn sessionId;
}
Cookie flags: httpOnly; Secure; SameSite=Lax|Strict; Path=/. Regenerate the session ID on privilege change (login, step-up) to kill session fixation. Absolute (24h) + idle (30m) timeouts. Clear ALL sessions on password change/reset. Store session IDs server-side hashed if they double as bearer tokens.
API Keys
functiongenerateApiKey() {
const key = `sk_live_${crypto.randomBytes(32).toString("base64url")}`;
return { key, prefix: key.slice(0, 16), hash: sha256(key) }; // show key ONCE; store only the hash
}
Store only the hash (fast SHA-256 is fine here — keys are high-entropy, unlike passwords). Prefix (sk_live_…) enables identification, log redaction, and secret-scanner detection. Attach scopes + per-key rate limit + expiry. Rotate without downtime by allowing N active keys per principal.
Password Hashing
// argon2id — DEFAULT for new systems (memory-hard → resists GPU/ASIC cracking)await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB (OWASP floor 19 MiB; raise until ~0.5–1s/hash on your hardware)timeCost: 3, // iterationsparallelism: 4,
});
// bcrypt — acceptable legacy; cost ≥ 12await bcrypt.hash(password, 12);
⚠ Why these and not others:
argon2id blends argon2i (side-channel resistance) + argon2d (GPU resistance) — the recommended default. Tune params to your box, not blindly copy.
bcrypt silently truncates input at 72 bytes — long passwords / a pepper appended past 72 bytes are ignored. Pre-hash (base64(sha256(pw))) before bcrypt if you must exceed 72 bytes.
Never MD5/SHA-1/SHA-256/unsalted for passwords — GPUs do billions/sec; fast hashes are the wrong tool. Salt is built into argon2/bcrypt output; don't roll your own.
Verify with the library's verify/compare (constant-time). Never hash(input) == stored.
Peppers (an app-side secret added before hashing) belong in a KMS/HSM, not the DB.
MFA
// TOTP: verify with a ±1 step window for clock skew; store the secret encrypted at rest
authenticator.verify({ token, secret }); // otplib default window handles skew
Comparing secrets with ==/===/strcmp leaks length and prefix via early-exit timing — an attacker measures response time to recover a token/HMAC byte-by-byte. Use constant-time compare for any secret equality: session tokens, API keys, password-reset tokens, HMAC/webhook signatures, MFA codes.
import { timingSafeEqual } from"crypto";
functionsafeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a), bb = Buffer.from(b);
if (ba.length !== bb.length) returnfalse; // length is not secret here; lengths already differreturntimingSafeEqual(ba, bb);
}
Equivalents: Python hmac.compare_digest, Go crypto/subtle.ConstantTimeCompare, Rust subtle/ring. For hashed values (argon2/bcrypt) the library's verify already handles this.
Security Checklist (verify before done)
Credentials & tokens:
Passwords hashed with argon2id (or bcrypt cost ≥12); no fast/unsalted hashes; input ≤72 bytes for bcrypt or pre-hashed
Refresh tokens, API keys, MFA secrets, reset tokens stored hashed/encrypted, never plaintext
JWT verify pins algorithms; validates exp, nbf, iss, aud; RS256/ES256 (no alg:none, no HS256 in distributed systems)
No secrets in JWT claims, logs, URLs, or error messages
All secret/token/HMAC comparisons are constant-time
Access control:
Every object access checks ownership/tenant in the query filter (no IDOR/BOLA); default-deny
Write/batch/nested operations authorized, not just reads
Client cannot set id/role/tenant/isAdmin via mass assignment
:own-scoped permissions still perform per-object checks
Flows & sessions:
OAuth: state verified, redirect_uri allowlisted (exact), PKCE S256
Session ID regenerated on login/step-up; httpOnly; Secure; SameSite cookies; absolute + idle timeouts; all sessions cleared on password change
Rate limiting + lockout on auth endpoints; identical error for bad-user vs bad-password (no user enumeration); timing between the two paths comparable
MFA re-verified for sensitive actions; backup codes hashed & single-use
HTTPS/HSTS enforced on all auth endpoints; auth events logged for audit (without secrets)
Complete Login Flow (reference)
asyncfunctionlogin(email, password, mfaCode) {
const user = await db.users.findUnique({ where: { email } });
// Run a dummy verify even when user is null so timing doesn't reveal account existence.const ok = awaitverifyPasswordArgon2(user?.passwordHash ?? DUMMY_HASH, password);
if (!user || !ok) {
if (user) awaitincrementFailedAttempts(user.id);
thrownewUnauthorizedError("Invalid credentials"); // same message either branch
}
if (user.lockedUntil && user.lockedUntil > newDate()) thrownewUnauthorizedError("Account locked");
if (user.mfaEnabled) {
if (!mfaCode) return { requiresMfa: true, mfaToken: generateMfaToken(user.id) };
if (!(awaitverifyMFA(user.id, mfaCode))) thrownewUnauthorizedError("Invalid MFA code");
}
await db.users.update({ where: { id: user.id }, data: { failedAttempts: 0, lockedUntil: null } });
return { accessToken: signToken({ sub: user.id, roles: user.roles }), refreshToken: awaitcreateRefreshToken(user.id) };
}