---
name: forge-auth
description: Authentication and session discipline. argon2id password hashing, server-side sessions vs JWT, OAuth 2.1 + PKCE, MFA via TOTP / WebAuthn, password reset hygiene, anti-enumeration timing, rate-limited login. Contains ready-to-paste session middleware, password hash helpers, MFA enrollment, reset flow. Use when designing or auditing auth - the highest-leverage place for security bugs to ship.
license: MIT
---

# forge-auth

You are writing authentication code. Default agent-written auth uses MD5 on passwords, stores JWTs in `localStorage`, generates session tokens with `Math.random()`, and lets reset links live for a week. Each of those is a documented breach pattern. This skill exists to fix them.

The mental model: auth is the perimeter. Every shortcut here becomes an incident later. Use boring, proven primitives. Resist the urge to invent.

## Quick reference (the things you must never ship)

1. `crypto.createHash('sha256')` (or md5/sha1) used on a password.
2. `Math.random()` for session IDs, tokens, nonces, or reset codes.
3. JWT decoded with `jwt.decode(...)` (no signature verification).
4. JWT verification accepting `alg: 'none'` or no explicit `algorithms`.
5. JWT stored in `localStorage` or `sessionStorage` for browser auth.
6. Cookie without `HttpOnly`, `Secure`, `SameSite`.
7. Bearer token in URL query string.
8. Reset token stored unhashed in the database.
9. Different response or response timing for "wrong password" vs "no such user."
10. Password reset link that auto-logs in.

## Hard rules

### Passwords

**1. Hash with argon2id (preferred) or bcrypt cost 12+.** Never SHA-256, never MD5, never plain.

```ts
// reference: argon2id
import * as argon2 from "argon2";

export async function hashPassword(plain: string): Promise<string> {
  return argon2.hash(plain, {
    type: argon2.argon2id,
    memoryCost: 65536,  // 64 MiB
    timeCost: 3,
    parallelism: 1,
  });
}

export async function verifyPassword(hash: string, candidate: string): Promise<boolean> {
  return argon2.verify(hash, candidate);
}
```

**2. Length is the only meaningful requirement.** 8 characters minimum, no maximum below 64. Composition rules ("must have a number, a symbol") are useless theater that pushes users to `Password1!`.

**3. Check against known-breached passwords.** Have I Been Pwned API (k-anonymity range query) or local Pwned Passwords download.

```ts
import { createHash } from "node:crypto";

async function isBreached(password: string): Promise<boolean> {
  const hash = createHash("sha1").update(password).digest("hex").toUpperCase();
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);
  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const lines = (await res.text()).split("\n");
  return lines.some((line) => line.split(":")[0]?.trim() === suffix);
}
```

**4. No password hints, no security questions.** Both are weaker than the password they protect.

### Sessions

**5. Server-side sessions for human users. JWT for service-to-service.** Server sessions revoke instantly; JWTs do not.

**6. Session ID is 128+ bits of CSPRNG output.** `crypto.randomBytes(32).toString('base64url')`. Never `Math.random()`, never `Date.now()`-derived.

```ts
import { randomBytes } from "node:crypto";

export function newSessionId(): string {
  return randomBytes(32).toString("base64url");
}
```

**7. Cookies: `HttpOnly`, `Secure`, `SameSite=Lax` (or `Strict`), `Path=/`.** All four. No exceptions.

```ts
// Hono example
c.cookie("session", id, {
  httpOnly: true,
  secure: true,
  sameSite: "Lax",
  path: "/",
  maxAge: 7 * 24 * 60 * 60,  // 7 days
});
```

**8. Session storage server-side (Redis or Postgres) keyed by session ID. Cookie carries the ID, not the data.**

**9. Session lifetime: 7-30 days sliding, idle timeout 12-24 hours.** Absolute max 90 days. Forever-sessions are a target.

**10. Invalidate sessions on password change, MFA enable, account takeover events.** Every active session of that user.

**11. Logout deletes the server-side session.** Not just clear the cookie. Token invalid for replay.

### JWT (when you do use it)

**12. JWT for short-lived access tokens between services. Pair with refresh tokens that rotate.** Access 5-15 minutes; refresh 7-30 days, single-use, revocable.

**13. Sign with RS256 or EdDSA (asymmetric).** Avoid HS256 unless you control both sides AND can rotate the shared secret.

**14. `alg: 'none'` is forbidden. Reject any token with it explicitly.**

```ts
import jwt from "jsonwebtoken";

// BAD - accepts anything
const decoded = jwt.decode(token);

// BAD - missing algorithm pin
const decoded = jwt.verify(token, publicKey);

// GOOD - explicit algorithm
const decoded = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],  // explicit, never "none"
  issuer: "https://yourapi.example.com",
  audience: "your-api",
  clockTolerance: 30,  // seconds
});
```

**15. Validate `iss`, `aud`, `exp`, `nbf` on every verify.** Confused-deputy attacks rely on accepting any IdP.

**16. Never store JWTs in `localStorage` for browser clients.** Use HttpOnly cookies. localStorage is XSS-readable; cookies are not.

### OAuth

**17. PKCE for every OAuth flow, including server-side.** The "PKCE is for public clients" advice is outdated.

**18. State parameter is mandatory.** CSPRNG nonce bound to the user's session. Mismatched state = abort.

**19. Validate the `iss` claim of the ID token against the issuer URL.**

**20. Redirect URIs are exact-match allowlisted. No wildcards.** `https://example.com/callback` matches only that; not `https://example.com/callback/evil`.

**21. Token endpoint uses client secret in request body, not in URL.**

### MFA

**22. TOTP (RFC 6238) via authenticator apps as the default second factor.** Universal, no SMS cost, works offline.

```ts
import { generateSecret, generateUri, verifyToken } from "@otplib/preset-default";

// enrollment
const secret = generateSecret();
const uri = generateUri("MyApp", user.email, secret);
// show QR code of uri; user scans it

// verification on every login (and on enrollment confirmation)
const ok = verifyToken({ token: userInput, secret });
```

**23. WebAuthn / passkeys when supported.** Phishing-resistant; strongest available factor.

**24. SMS as fallback only.** Vulnerable to SIM swap.

**25. MFA setup requires entering a code to confirm enrollment.** Without confirmation, malformed setup locks the user out.

**26. Recovery codes generated at setup, displayed once, hashed at rest, single-use. 10 codes is standard.**

```ts
import { randomBytes, createHash } from "node:crypto";

function generateRecoveryCodes(): { plain: string; hashed: string }[] {
  return Array.from({ length: 10 }, () => {
    const plain = `${randomBytes(4).toString("base64url")}-${randomBytes(4).toString("base64url")}`;
    const hashed = createHash("sha256").update(plain).digest("hex");
    return { plain, hashed };
  });
}
```

### Password reset

**27. Reset link single-use, expires in 60 minutes, invalidates on use OR password change OR logout-all.**

**28. Reset token is CSPRNG, hashed at rest in DB.** Link contains raw token; DB has hash.

```ts
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";

async function startReset(email: string): Promise<void> {
  const user = await db.users.findByEmail(email);
  if (!user) {
    // Always send the same response - see rule 30.
    return;
  }
  const raw = randomBytes(32).toString("base64url");
  const hash = createHash("sha256").update(raw).digest("hex");
  await db.password_resets.create({
    user_id: user.id,
    token_hash: hash,
    expires_at: new Date(Date.now() + 60 * 60 * 1000),
  });
  await mailer.send(user.email, "reset", { reset_url: `https://app.example.com/reset?t=${raw}` });
}

async function completeReset(rawToken: string, newPassword: string): Promise<{ ok: boolean }> {
  const hash = createHash("sha256").update(rawToken).digest("hex");
  const record = await db.password_resets.findValidByHash(hash);
  if (!record) return { ok: false };
  await db.password_resets.consume(record.id);
  await db.users.updatePassword(record.user_id, await hashPassword(newPassword));
  await db.sessions.deleteAllForUser(record.user_id);  // forge-auth rule 10
  return { ok: true };
}
```

**29. Reset link does not log the user in. It lets them set a new password.** A reset that auto-logs is full account compromise if email is leaked.

**30. Reset email reveals nothing about whether the account exists.** Always show "If an account exists for this email, we sent a link." Always.

### Rate limiting

**31. Login endpoint rate-limited: 5 attempts per IP per minute, exponential backoff per account.**

**32. Reset and signup endpoints rate-limited too.** Both are enumeration vectors.

**33. CAPTCHA after N failures, not on every request.**

### Account enumeration

**34. Same response time and same response body for "user exists, wrong password" and "user does not exist."**

```ts
// reference: constant-time login response
async function login(email: string, password: string): Promise<Result<Session, "invalid_credentials">> {
  const user = await db.users.findByEmail(email);

  // Hash the candidate even if no user, to keep timing similar.
  const dummyHash = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
  const hashToCompare = user?.password_hash ?? dummyHash;

  const ok = await argon2.verify(hashToCompare, password);

  if (!user || !ok) {
    return err("invalid_credentials");
  }
  return ok(await createSession(user.id));
}
```

**35. Signup with existing email returns success, sends an email saying "account already exists."**

### Logging

**36. Log auth events: login attempt, login success, MFA challenge, password change, reset, session creation, logout. With IP, user agent, timestamp.**

**37. Never log passwords, tokens, MFA codes, or reset tokens.** See [`forge-secrets`](../../security/forge-secrets/SKILL.md).

**38. Surface "recent activity" to the user.** Empowers them to spot unauthorized access.

## Common AI-output patterns to reject

| Pattern | Why dangerous | Fix |
| --- | --- | --- |
| `crypto.createHash('sha256')` on password | Crackable in seconds | argon2id |
| `Math.random()` for session | Predictable | `crypto.randomBytes(32)` |
| `jwt.decode(...)` | No signature check | `jwt.verify(token, key, { algorithms: ['RS256'] })` |
| `localStorage.setItem('jwt', ...)` | XSS-readable | HttpOnly cookie |
| Cookie missing HttpOnly/Secure/SameSite | Various attack classes | Set all four |
| `Authorization` token in `?token=...` | Logged + cached | Header only |
| Reset token stored plaintext in DB | DB leak = account takeover | Store `sha256(token)` |
| Different timing on "user not found" | Enumeration | Constant-time path |
| Reset link signs the user in | Email leak = full compromise | Reset link = form to set new password |
| `if (err) console.log(password)` | Disaster | Logger redaction (see forge-logging) |

## Worked example: complete session middleware

```ts
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";

const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const IDLE_TTL_MS    = 12 * 60 * 60 * 1000;

export async function createSession(userId: string): Promise<{ id: string; expires_at: Date }> {
  const id = randomBytes(32).toString("base64url");
  const hash = createHash("sha256").update(id).digest("hex");
  const expires = new Date(Date.now() + SESSION_TTL_MS);
  await redis.set(`session:${hash}`, JSON.stringify({ user_id: userId, last_seen: Date.now() }), "PX", SESSION_TTL_MS);
  return { id, expires_at: expires };
}

export const sessionMiddleware: MiddlewareHandler = async (c, next) => {
  const id = c.req.cookie(SESSION_COOKIE);
  if (!id) return next();

  const hash = createHash("sha256").update(id).digest("hex");
  const raw = await redis.get(`session:${hash}`);
  if (!raw) return next();

  const session = JSON.parse(raw) as { user_id: string; last_seen: number };
  if (Date.now() - session.last_seen > IDLE_TTL_MS) {
    await redis.del(`session:${hash}`);
    return next();
  }
  // Update last_seen (sliding window)
  session.last_seen = Date.now();
  await redis.set(`session:${hash}`, JSON.stringify(session), "PX", SESSION_TTL_MS);

  c.set("user_id", session.user_id);
  await next();
};

export async function destroySession(id: string): Promise<void> {
  const hash = createHash("sha256").update(id).digest("hex");
  await redis.del(`session:${hash}`);
}

export async function destroyAllSessionsFor(userId: string): Promise<void> {
  // requires a secondary index session-by-user, omitted for brevity
  const sessionHashes = await redis.smembers(`user_sessions:${userId}`);
  for (const h of sessionHashes) {
    await redis.del(`session:${h}`);
  }
  await redis.del(`user_sessions:${userId}`);
}

// On login:
//   const { id } = await createSession(user.id);
//   c.cookie(SESSION_COOKIE, id, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: SESSION_TTL_MS / 1000 });
```

What this shows: 256-bit CSPRNG IDs (rule 6); session ID hashed at rest in Redis (so a Redis dump does not enable replay); sliding idle window (rule 9); proper destroy on logout (rule 11); destroyAllSessionsFor on password change (rule 10).

## Workflow

When designing or auditing auth:

1. **Use a library.** Auth0, Clerk, Supabase Auth, Lucia, NextAuth, Devise, Django auth. Rolling your own is for learning, not for shipping.
2. **If you must roll your own, use proven primitives:** argon2id, CSPRNG, RFC 6238, WebAuthn.
3. **Threat-model.** What does compromise of [password DB, session cookies, email account, refresh token] enable?
4. **Write an abuse cases doc.** Reset spam, credential stuffing, account enumeration, session fixation.
5. **Get a real security review before launch.**

## Verification

```bash
bash skills/backend/forge-auth/verify/check_auth.sh path/to/auth.ts
```

Flags: weak hashes near password fields, Math.random for tokens, JWT decoded without verify, JWT in localStorage, `alg: 'none'` accepted.

## When to skip this skill

- Internal tools with no real auth threat model.
- Service-to-service auth via mTLS (different discipline).
- Auth handled by an upstream provider (Cloudflare Access, Azure AD) where you only consume identity headers.

## Related skills

- [`forge-secrets`](../../security/forge-secrets/SKILL.md) - the foundation auth sits on.
- [`forge-api-design`](../forge-api-design/SKILL.md) - 401 vs 403 status codes, error shape.
- [`forge-logging`](../forge-logging/SKILL.md) - redaction so passwords/tokens never log.
- [`forge-validation`](../forge-validation/SKILL.md) - schema-validated auth inputs.
