Auth — Authentication & Authorization
1. Philosophy
- Sessions for SSR, tokens for SPA — Choose model by architecture.
- httpOnly cookies only — Never localStorage/sessionStorage for credentials.
- Rotate + detect reuse — Refresh tokens rotate; reuse = revoke all.
- Argon2 for passwords — Never bcrypt/scrypt in new projects.
- PKCE mandatory — OAuth public clients must use PKCE.
2. Session vs Token
| Factor | Sessions (Cookie) | JWT (Token) |
|---|---|---|
| Architecture | SSR, traditional | SPA, microservices |
| Storage | httpOnly cookie | httpOnly cookie (never localStorage) |
| Revocation | Immediate (server) | Requires blocklist / short expiry |
| Scalability | Shared store (Redis) | Stateless |
| CSRF risk | Yes (mitigate) | No (if cookie SameSite=Strict) |
| Payload size | Session ID only | Claims embedded |
Decision
- Next.js / Astro SSR → Sessions (cookie + Redis)
- React SPA / Vite → JWT in httpOnly cookie
- Mobile / native → JWT (no cookie support)
3. Client Credential Storage
httpOnly Cookie (mandatory)
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=604800
Cookie attributes
| Attribute | Access Token | Refresh Token |
|---|---|---|
HttpOnly |
✅ | ✅ |
Secure |
✅ | ✅ |
SameSite |
Lax |
Strict |
Path |
/ |
/auth/refresh |
__Host- prefix |
✅ | ✅ |
Max-Age |
900 (15 min) | 604800 (7 days) |
Rules
__Host-prefix — requiresSecure,Path=/, noDomain- Never
localStorage/sessionStorage— XSS steals tokens - Short-lived access — 15 min max
- Refresh restricted path — only sent to
/auth/refresh
4. JWT
Structure
// Header
{ "alg": "RS256", "typ": "JWT" }
// Payload
{
"sub": "usr_abc123",
"email": "user@example.com",
"roles": ["user"],
"iat": 1704067200,
"exp": 1704068100,
"jti": "tok_xyz789"
}
Validation (server)
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://api.example.com",
audience: "https://app.example.com",
maxTokenAge: "15m",
});
return payload;
}
Rules JWT
- RS256/ES256 — asymmetric, rotate keys via JWKS
- Short expiry — 15 min access, 7 days refresh
jticlaim — unique ID for revocation- Validate
iss,aud,exp,nbf— always - JWKS endpoint —
/.well-known/jwks.jsonfor key rotation
5. Access + Refresh Tokens
Rotation + Reuse Detection
// Refresh endpoint
async function refresh(refreshToken: string) {
const payload = await verifyToken(refreshToken);
// Check reuse
const existing = await db.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!existing || existing.revoked) {
// REUSE DETECTED — revoke entire family
await db.refreshToken.updateMany({
where: { familyId: existing.familyId },
data: { revoked: true },
});
throw new Error("Token reuse detected");
}
// Rotate: revoke old, issue new pair
await db.refreshToken.update({
where: { id: existing.id },
data: { revoked: true },
});
const newAccess = createAccessToken(payload.sub);
const newRefresh = await createRefreshToken(payload.sub, existing.familyId);
return { accessToken: newAccess, refreshToken: newRefresh };
}
Rules Access + Refresh Tokens
- Rotate on every refresh — new access + new refresh
- Reuse = revoke family — immediate logout everywhere
- Refresh in httpOnly cookie —
SameSite=Strict, path/auth/refresh - Access in httpOnly cookie —
SameSite=Lax - Track family ID — link all tokens from same login
6. Server-Side Sessions
// Session store (Redis)
interface SessionData {
userId: string;
roles: string[];
createdAt: number;
lastActivity: number;
}
// Create
await redis.setex(`session:${sessionId}`, 86400, JSON.stringify(data));
// Validate
const session = await redis.get(`session:${sessionId}`);
if (!session) throw new Error("Invalid session");
Rules Server-Side Sessions
- Redis with TTL — auto-expiry
- Session ID in httpOnly cookie —
__Host-session_id - Rotate on privilege change — new session on role change
- Destroy on logout —
DEL+ clear cookie
7. Password Hashing
import { hash, verify } from "@node-rs/argon2";
// Hash (register)
const hash = await hash(password, {
memoryCost: 19456, // 19 MB
timeCost: 2, // iterations
parallelism: 1,
outputLen: 32,
type: "Argon2id",
});
// Verify (login)
const valid = await verify(hash, password);
Rules Password Hashing
- Argon2id only —
argon2npm or@node-rs/argon2 - Memory cost ≥ 19 MB — resist GPU
- Time cost ≥ 2 — tune for ~100ms
- Never bcrypt/scrypt — legacy
- Constant-time verify — prevents timing attacks
8. Password Reset
// Request reset
const token = crypto.randomBytes(32).toString("hex");
const hash = await hashToken(token);
await db.passwordReset.create({
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
});
await email.send(user.email, { resetToken: token });
// Confirm reset
const record = await db.passwordReset.findFirst({ where: { userId } });
if (!record || record.expiresAt < new Date())
throw new Error("Invalid/expired");
const valid = await verify(record.tokenHash, token);
if (!valid) throw new Error("Invalid token");
await updatePassword(userId, newPassword);
await db.passwordReset.delete({ where: { id: record.id } });
Rules Password Reset
- Token = 32 bytes random — not guessable
- Hash stored — not plaintext
- 1-hour expiry — short window
- Single use — delete after use
- Rate limit requests — 3/hour per email
9. OAuth 2.0 / OIDC / PKCE
PKCE Flow (mandatory for public clients)
// 1. Generate code verifier + challenge
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
await crypto.subtle.digest("SHA-256", codeVerifier),
);
// 2. Redirect to auth server
const url = `https://auth.example.com/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=openid profile email&
code_challenge=${codeChallenge}&
code_challenge_method=S256&
state=${state}`;
// 3. Callback: exchange code for tokens
const tokens = await fetch("https://auth.example.com/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
});
Rules OAuth 2.0
- PKCE mandatory — prevents code interception
stateparameter — CSRF protection- Validate
redirect_uriexactly — pre-registered - Store tokens in httpOnly cookies — not localStorage
10. MFA / TOTP
import { generateSecret, verifyToken } from "otplib";
// Setup
const secret = generateSecret();
const otpauth = `otpauth://totp/Example:user@example.com?secret=${secret}&issuer=Example`;
// Verify
const valid = verifyToken(userInput, user.totpSecret);
Rules MFA / TOTP
- TOTP (RFC 6238) — 30s window, 6 digits
- Backup codes — 8 single-use codes on setup
- Enforce for admins — optional for users
- Rate limit attempts — 5/min
11. WebAuthn / Passkeys
// Registration
const options = await generateRegistrationOptions({
rpName: "Example",
rpID: "example.com",
userID: user.id,
userName: user.email,
userDisplayName: user.name,
authenticatorSelection: {
residentKey: "preferred",
userVerification: "required",
},
});
// Send options to client -> navigator.credentials.create()
// Authentication
const options = await generateAuthenticationOptions({ rpID: "example.com" });
// Client -> navigator.credentials.get() -> send to server -> verify
Rules WebAuthn
- Passkeys preferred — discoverable credentials
- User verification required — biometric/PIN
- Resident keys — no username entry needed
- Fallback to password — during transition
12. Protected Routes
Server (middleware)
// Next.js middleware.ts
export async function middleware(request: NextRequest) {
const token = request.cookies.get("access_token")?.value;
if (!token) return redirect("/login");
try {
await verifyToken(token);
return NextResponse.next();
} catch {
return redirect("/login");
}
}
Client (React)
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Skeleton />;
if (!user) return <Navigate to="/login" replace />;
return children;
}
13. CSRF
CSRF prevention: see
securityskill.
Summary
- SameSite cookies —
Laxfor access,Strictfor refresh - Double-submit cookie — for non-GET if SameSite unavailable
- Custom header —
X-CSRF-Tokenfor API mutations
14. XSS
XSS prevention: see
securityskill.
Summary XSS
- Content-Type: application/json — no sniffing
- CSP header —
script-src 'self' - Sanitize user content — DOMPurify for HTML
15. Session State (Client)
// React context
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/auth/me")
.then((res) => res.json())
.then((data) => {
setUser(data.user);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
return (
<AuthContext.Provider value={{ user, setUser, loading }}>
{children}
</AuthContext.Provider>
);
}
16. Anti-Abuse
| Measure | Config |
|---|---|
| Login rate limit | 5 attempts / 15 min / IP |
| Registration limit | 3 / hour / IP |
| Password reset limit | 3 / hour / email |
| MFA attempts | 5 / min / user |
| Account lockout | 30 min after 10 failures |
| Bot detection | Turnstile / hCaptcha on auth forms |
17. Methodology
Before using ANY auth pattern not documented in this skill:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor jose, oidc-client-ts, WebAuthn. - Official docs: OWASP Auth Cheatsheet, RFC 7519, RFC 7636, WebAuthn spec.
- Project config:
package.json, auth middleware, cookie config — verify against actual setup. - HARD RULE: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.
18. Prohibitions
- ❌ Do not store tokens in localStorage/sessionStorage
- ❌ Do not use symmetric JWT (HS256) — use RS256/ES256
- ❌ Do not skip refresh token rotation
- ❌ Do not skip reuse detection
- ❌ Do not use bcrypt for new projects — Argon2id only
- ❌ Do not expose tokens in URLs
- ❌ Do not use OAuth without PKCE
- ❌ Do not skip rate limiting on auth endpoints
- ❌ Do not use SameSite=None without Secure
19. References
Note: For API design (auth endpoints), see API Design Note: For JavaScript conventions (crypto, fetch), see JavaScript Note: For Security (CSRF, XSS, rate limiting), see Security Note: For Next.js patterns, see Next.js Note: For React patterns, see React
Last updated: 2026-08