Explain design decisions and security trade-offs: - Why stateless JWTs (not stored in DB) - Why token versioning over blacklisting - Why verification tokens are plain strings - Why bcrypt with 12 rounds Include security features table, token lifecycle diagram, and configuration reference.
7.9 KiB
Authentication Architecture
This document explains the design decisions and security features of the Super Sync Server authentication system.
Overview
The server uses stateless JWT authentication with:
- Token versioning for revocation (no blacklist needed)
- Email verification for new accounts
- Account lockout protection against brute force
Design Decisions
Why Stateless JWTs (Not Stored in DB)
JWTs are issued but never persisted to the database.
Benefits:
- No session store needed - scales horizontally without shared state
- Reduced DB load - no lookup on every authenticated request
- Simpler architecture - no session cleanup jobs
Trade-off:
- Cannot revoke individual tokens - only all tokens via version increment
- Token remains valid until expiry (7 days) unless version bumped
Why Token Versioning (Not Blacklisting)
Each user has a tokenVersion integer. JWTs include this version, and verification checks it matches the current DB value.
Token issued with version 5 → User changes password → DB version becomes 6 → Token rejected (5 ≠ 6)
Benefits:
- O(1) storage per user (single integer vs. unbounded blacklist)
- No cleanup jobs needed (blacklists grow indefinitely)
- Instant revocation of ALL tokens with one DB update
Trade-off:
- Cannot selectively revoke one device's token
- All devices must re-authenticate when version changes
When version increments:
- Password change
- Explicit "log out all devices" action
- Token replacement (
/api/replace-token)
Why Verification Tokens Are Plain Strings
Email verification tokens are stored as plain 64-character hex strings (32 random bytes).
Why this is acceptable:
- Cryptographically unguessable - 256 bits of entropy from
crypto.randomBytes(32) - One-time use - Cleared immediately after successful verification
- Short-lived - 24-hour expiry
- Low-value target - Only activates an account, grants no ongoing access
Trade-off:
- If DB is compromised, attacker could verify pending (unverified) accounts
- Minimal impact: they still don't know the password
Alternative considered: Hashing verification tokens (like password reset tokens in some systems) would add complexity with minimal security benefit for this use case.
Why bcrypt with 12 Rounds
Why bcrypt:
- Industry standard, battle-tested
- Built-in salt generation
- Resistant to GPU/ASIC attacks (memory-hard)
Why 12 rounds:
- ~250ms on modern hardware (balances security and UX)
- OWASP recommends 10+ rounds
- Adjustable if hardware improves
Security Features
| Feature | Implementation | Value |
|---|---|---|
| Password hashing | bcrypt | 12 rounds |
| Password minimum | Zod validation | 12 characters |
| JWT signing | HMAC-SHA256 | Secret min 32 chars |
| JWT expiry | Configurable | 7 days |
| Verification token | crypto.randomBytes |
32 bytes (256 bits) |
| Verification expiry | Time-based | 24 hours |
| Lockout threshold | Failed attempts | 5 attempts |
| Lockout duration | Time-based | 15 minutes |
| Timing attack mitigation | Dummy hash comparison | Always compare |
Timing Attack Mitigation
Even when a user doesn't exist, the login flow compares the provided password against a dummy hash. This ensures the response time is consistent whether the user exists or not, preventing attackers from enumerating valid emails.
const dummyHash = '$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW';
const hashToCompare = user ? user.passwordHash : dummyHash;
await bcrypt.compare(password, hashToCompare);
Token Lifecycle
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Register │────▶│ Verification │────▶│ Verified │
│ (email + │ │ Token (24h) │ │ Account │
│ password) │ │ sent via email │ │ │
└─────────────┘ └──────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ Login │
│ (email + │
│ password) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ JWT (7 days) │
│ contains: │
│ - userId │
│ - email │
│ - tokenVersion │
└────────┬────────┘
│
┌────────────────────────┴────────────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Token expires │ │ Password change │
│ (after 7 days) │ │ tokenVersion++ │
│ │ │ │
│ User must │ │ ALL tokens │
│ re-login │ │ invalidated │
└─────────────────┘ └─────────────────┘
API Reference
See README.md for endpoint documentation.
Password requirements:
- Minimum 12 characters (validated via Zod schema in
api.ts)
JWT Secret requirements:
- Minimum 32 characters
- Generate with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Configuration
All auth-related constants are defined in src/auth.ts:
const MIN_JWT_SECRET_LENGTH = 32;
const BCRYPT_ROUNDS = 12;
const JWT_EXPIRY = '7d';
const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_FAILED_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
To modify these values, edit src/auth.ts and rebuild.
Future Considerations
Features not currently implemented but could be added:
- 2FA/MFA - TOTP or WebAuthn
- Refresh tokens - Separate short-lived access + long-lived refresh
- Per-device token revocation - Track device IDs in JWT
- Rate limiting per-user - Currently only IP-based for auth endpoints