packages/super-sync-server/docs/authentication.md
This document explains the design decisions and security features of the Super Sync Server authentication system.
The server uses stateless JWT authentication with:
JWTs are issued but never persisted to the database.
Benefits:
Trade-off:
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:
Trade-off:
When version increments:
/api/replace-token)Email verification tokens are stored as plain 64-character hex strings (32 random bytes).
Why this is acceptable:
crypto.randomBytes(32)Trade-off:
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:
Why 12 rounds:
| Feature | Implementation | Value |
|---|---|---|
| Password hashing | bcrypt | 12 rounds |
| Password minimum | Zod validation | 12 characters |
| JWT signing | HMAC-SHA256 | Secret min 32 chars |
| JWT expiry | Uniform | 365 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 |
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);
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Register │────▶│ Verification │────▶│ Verified │
│ (email + │ │ Token (24h) │ │ Account │
│ password) │ │ sent via email │ │ │
└─────────────┘ └──────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ Login │
│ (email + │
│ password) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ JWT (365d) │
│ contains: │
│ - userId │
│ - email │
│ - tokenVersion │
└────────┬────────┘
│
┌────────────────────────┴────────────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Token expires │ │ Password change │
│ (after expiry) │ │ tokenVersion++ │
│ │ │ │
│ User must │ │ ALL tokens │
│ re-login │ │ invalidated │
└─────────────────┘ └─────────────────┘
See README.md for endpoint documentation.
Password requirements:
api.ts)JWT Secret requirements:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"All auth-related constants are defined in src/auth.ts:
const MIN_JWT_SECRET_LENGTH = 32;
const BCRYPT_ROUNDS = 12;
const JWT_EXPIRY = '365d'; // All JWT tokens, regardless of auth method
const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
To modify these values, edit src/auth.ts and rebuild.
Features not currently implemented but could be added: