Security#
AuthHero is built with security as a first-class concern. This page documents every security measure, the rationale behind each decision, and how they protect against common attack vectors.
Password Hashing — Argon2#
Passwords are hashed using Argon2id, the winner of the Password Hashing Competition and the current OWASP recommendation. Argon2id combines Argon2i (side-channel resistant) and Argon2d (GPU-resistant) for the best overall security.
| Setting | Value | Purpose |
|---|---|---|
Algorithm | Argon2id | Balanced resistance against side-channel and GPU attacks |
Used for | Passwords + MFA backup codes | All user secrets |
Verification | Constant-time comparison | Prevents timing attacks |
Argon2 is memory-hard (configurable memory cost), making it significantly more expensive to brute-force with GPUs/ASICs compared to bcrypt, which is only CPU-bound.
Token Security#
Never Store Secrets in Plaintext#
| Secret | Storage Method | Why |
|---|---|---|
Passwords | Argon2 hash | One-way — cannot be reversed |
Refresh tokens | SHA-256 hash | Fast lookup with index; one-way |
Verification tokens | SHA-256 hash | Same pattern as refresh tokens |
Password reset tokens | SHA-256 hash | Same pattern |
MFA TOTP secrets | AES-256-GCM encryption | Must be decryptable for verification |
MFA backup codes | Argon2 hash | One-way — same security as passwords |
Cryptographically Random Tokens#
All tokens are generated using crypto.randomBytes() from Node.js's built-in crypto module, which uses the OS entropy pool (CSPRNG).
| Token | Length | Entropy |
|---|---|---|
Refresh token | 40 bytes (80 hex chars) | 320 bits |
Verification token | 36 bytes (72 hex chars) | 288 bits |
Password reset token | 36 bytes (72 hex chars) | 288 bits |
OAuth one-time code | 32 bytes (64 hex chars) | 256 bits |
OAuth state (CSRF) | 32 bytes (64 hex chars) | 256 bits |
MFA backup code | 4 bytes (8 hex chars) | 32 bits (but argon2-hashed + rate-limited) |
MFA Secret Encryption — AES-256-GCM#
TOTP secrets must be decrypted at runtime to verify codes, so they can't use one-way hashing. AuthHero encrypts them with AES-256-GCM:
| Property | Value |
|---|---|
Algorithm | AES-256-GCM (authenticated encryption) |
Key | MFA_ENCRYPTION_KEY (64-char hex = 32 bytes) |
IV | 12 random bytes (generated per encryption) |
Output format | iv:authTag:ciphertext (all hex) |
Integrity | GCM auth tag detects tampering |
If the MFA_ENCRYPTION_KEY is compromised, all TOTP secrets can be decrypted. If the key is lost, existing TOTP secrets become unrecoverable. Store this key in a secrets manager in production.
CSRF Protection#
AuthHero protects against CSRF through multiple mechanisms:
- SameSite=strict cookies — Refresh token cookies are never sent in cross-origin requests
- Access token in memory — The access token is stored in JavaScript memory (Zustand), not in cookies, so it can't be leaked via CSRF
- OAuth state parameter — Random state is stored in an httpOnly cookie and compared with the callback query parameter
XSS Protection#
- httpOnly cookies — Refresh tokens are in httpOnly cookies, invisible to JavaScript (immune to XSS)
- Helmet middleware — Sets security headers including Content-Security-Policy, X-Content-Type-Options, X-Frame-Options
- No tokens in URLs — OAuth tokens are stored in Redis behind one-time codes, never exposed in redirect URLs
Rate Limiting#
All authentication endpoints are rate-limited using Redis-backed rate limiters (express-rate-limit + rate-limit-redis):
| Endpoint | Limit | Window |
|---|---|---|
Register | 5 requests | 10 minutes |
Login | 10 requests | 15 minutes |
Verify email | 5 requests | 10 minutes |
Forgot password | 3 requests | 15 minutes |
Reset password | 5 requests | 15 minutes |
Refresh token | 10 requests | 15 minutes |
Change password | 3 requests | 15 minutes |
MFA challenge | 5 requests | 5 minutes |
Rate limiters are per-IP in development and can be customized per-user in production. The Redis backing enables rate limiting across multiple server instances.
CORS Policy#
CORS is configured with a strict origin whitelist:
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [env.FRONTEND_URL, env.APP_URL]
.filter(Boolean);
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true, // Allow cookies
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
};Only FRONTEND_URL and APP_URL origins are allowed. All other origins are rejected. credentials: true is required for cookies.
Security Headers (Helmet)#
Helmet sets a comprehensive suite of security headers on every response:
| Header | Value | Protection |
|---|---|---|
Content-Security-Policy | default-src 'self' | Prevents XSS, code injection |
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
X-Frame-Options | DENY | Prevents clickjacking |
X-XSS-Protection | 0 | Disabled (CSP is preferred over this) |
Strict-Transport-Security | max-age=15552000; includeSubDomains | Forces HTTPS |
Referrer-Policy | no-referrer | Prevents token leakage via Referer |
Email Enumeration Prevention#
The forgot-password endpoint always returns the same response regardless of whether the email exists:
{
"success": true,
"message": "If an account exists, a reset link has been sent."
}This prevents attackers from probing which emails are registered. The registration endpoint does return EMAIL_ALREADY_EXISTS since this is typically necessary for user experience (telling the user to log in instead).
Timing Attack Prevention#
When a login attempt fails, AuthHero still runs a dummy argon2 hash operation even if the user doesn't exist:
// If user not found, still hash to prevent timing-based email enumeration
if (!user) {
await hashPassword("dummy-password");
throw new AppError(401, "Invalid credentials", AppErrorCode.InvalidCredentials);
}This ensures the response time is the same whether the email exists or not, preventing timing-based enumeration.
Production Security Checklist#
| Item | Status |
|---|---|
Passwords hashed with Argon2id | ✅ Built-in |
Tokens stored as hashes (never plaintext) | ✅ Built-in |
MFA secrets encrypted with AES-256-GCM | ✅ Built-in |
Refresh token rotation with reuse detection | ✅ Built-in |
Rate limiting on all auth endpoints | ✅ Built-in |
CORS with strict origin whitelist | ✅ Built-in |
Helmet security headers | ✅ Built-in |
httpOnly, secure, SameSite=strict cookies | ✅ Built-in |
CSRF state for OAuth | ✅ Built-in |
Email enumeration prevention | ✅ Built-in |
Timing attack prevention | ✅ Built-in |
No tokens in URLs (OAuth one-time code pattern) | ✅ Built-in |
TLS/HTTPS in production | ⚠️ Configure via reverse proxy |
Secrets in environment variables / secrets manager | ⚠️ Your responsibility |
Regular dependency audits (npm audit) | ⚠️ Your responsibility |