Security / DevSecOps • Authentication & Access
Hardening an Admin Backend — MFA, Rate Limiting, and Lockout
Quick Links
Summary
- Problem: An admin backend is a high-value target. Password-only auth and untracked sessions leave it exposed to credential stuffing, brute force, and stolen-token replay.
- Solution: Layered controls — strong password hashing, two independent MFA methods, hashed session tracking with inactivity timeout, IP rate limiting, and account lockout — each covering a different failure mode.
- Impact: No single compromised control breaks authentication; stolen database rows yield only hashes, and a stolen password still hits MFA, lockout, and rate limiting.
- Key Decisions: bcrypt cost 12 over the common default of 10, SHA-256 hashing of session tokens at rest, dual MFA with hashed single-use recovery codes, generic errors to prevent account enumeration.
Context
This is the authentication layer for the admin backend of a multi-tenant auto-dealer SaaS, written in Go. Admin consoles concentrate privilege, so the threat model assumes attackers will try stolen credentials, brute force, and replay of leaked tokens. The design goal was depth — every control assumes the one in front of it might fail.
Symptoms / Failure Modes
- Admin surface reachable by credential stuffing if passwords are the only barrier
- Plaintext or signed-but-untracked tokens can't be revoked once issued
- A leaked database would hand an attacker usable session tokens
- Verbose login errors let attackers enumerate valid accounts
Goals, Requirements, Constraints
Goals
- Make a stolen password insufficient on its own to authenticate
- Allow sessions to be revoked and to expire on inactivity, not just on signature
- Ensure a database compromise yields no directly usable secrets
- Throttle and lock out automated guessing without locking out real users
Constraints
- JWT signing secret must be at least 32 characters, enforced at startup
- MFA must support both authenticator apps and users without one
- Tokens must never be written to logs or stored in plaintext
Non-Goals
- Replacing the auth core with a third-party identity provider
- Passwordless / WebAuthn flows (a candidate for a later phase)
- Distributed rate-limit state (current limiter is per-instance in-memory)
Acceptance Criteria
- Passwords stored only as bcrypt hashes with a cost of 12
- Sessions revocable server-side and expiring after 4 hours of inactivity
- MFA enforced via TOTP or email OTP, with single-use recovery codes
- Repeated failures trigger IP rate limiting and per-account lockout
Approach
Authentication is a pipeline of independent checks. A login request first passes IP rate limiting, then account-lockout and email-verification gates, then bcrypt password verification. If MFA is enabled, the password step yields only a short-lived MFA token; a second request validates a TOTP or email OTP code before any session is issued. Issued JWTs are tracked in the database by SHA-256 hash, so sessions can be revoked, timed out on inactivity, and cleaned up on a schedule.
Key Design Decisions
- Decision: bcrypt at cost factor 12
Why: Each step up roughly doubles the work to verify a guess; 12 is meaningfully stronger than the common default of 10 while staying acceptable for interactive login latency.
Alternatives: Cost 10 is faster but weaker against offline cracking; argon2 was considered but bcrypt kept the dependency surface small and is well understood. - Decision: Database-tracked JWTs with SHA-256-hashed tokens
Why: Tracking sessions in a table makes them revocable (logout, logout-all) and lets the server enforce a 4-hour inactivity timeout — things a stateless signed token can't do. Storing only the hash means a leaked database exposes no usable tokens.
Alternatives: Pure stateless JWTs can't be revoked before expiry; storing tokens in plaintext would turn a DB leak into a session-hijack kit. - Decision: Two independent MFA methods plus recovery codes
Why: TOTP serves users with an authenticator app; email OTP is a lower-barrier fallback; ten single-use, bcrypt-hashed recovery codes cover a lost device — so MFA is usable without being a lockout trap.
Alternatives: A single MFA method either excludes users without authenticator apps or has no recovery path when a device is lost. - Decision: Generic authentication errors
Why: Returning the same "invalid email or password" regardless of which field was wrong prevents attackers from enumerating valid accounts.
Alternatives: Distinct "no such user" vs "wrong password" messages leak account existence.
Implementation
Components / Modules
- Password module: bcrypt (cost 12) with strength rules — 8+ chars, mixed case, digit, special — plus a common-password blocklist.
- Session manager: HS256 JWTs (24h default) tracked in a sessions table by SHA-256 hash, with a 4-hour inactivity timeout and an hourly cleanup goroutine.
- MFA subsystem: RFC 6238 TOTP (6 digits, 30s window, 1-step skew) and 6-digit email OTP (10-minute expiry), with ten single-use bcrypt-hashed recovery codes per user.
- Abuse controls: In-memory IP rate limiter (10 attempts / 5 minutes) and per-account lockout after repeated failures, both with automatic expiry.
Automation & Delivery
- Auth unit tests runnable via go test ./pkg/auth/...
- Expired sessions and stale rate-limit entries reaped by background goroutines
- JWT secret length validated at process startup (fail-fast on misconfiguration)
// Session tokens are tracked by hash, never stored in the clear.
func StoreSession(db *sql.DB, jwt string, userID string, exp time.Time) error {
sum := sha256.Sum256([]byte(jwt))
tokenHash := hex.EncodeToString(sum[:])
_, err := db.Exec(
`INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at)
VALUES ($1, $2, $3, NOW())`,
userID, tokenHash, exp,
)
return err
}
Notable Challenges
- Sessions needed to be both stateless-fast and revocable — solved by validating the JWT signature first, then confirming the token's SHA-256 hash exists and is fresh in the sessions table.
- MFA had to be secure without becoming a lockout trap — solved with a second method (email OTP) and single-use recovery codes as an offline fallback.
- Rate limiting is currently per-instance in-memory; moving to a shared store (e.g. Redis) is the path to consistent limits across replicas.
Security
- Credential stuffing — leaked passwords replayed at scale: blunted by IP rate limiting and per-account lockout.
- Brute force — automated password guessing: capped by lockout duration and the rate-limit window.
- Session hijacking — replay of a stolen token: limited by hashed storage, signature checks, and a 4-hour inactivity timeout.
- Database compromise — exfiltrated rows: yields only bcrypt password hashes and SHA-256 token hashes, neither directly usable.
- Account enumeration — probing which emails exist: prevented by generic error responses.
Controls Implemented
- bcrypt (cost 12) password hashing with strength rules and a common-password blocklist
- SHA-256-hashed, database-tracked sessions with 4-hour inactivity timeout and scheduled cleanup
- Dual MFA (TOTP + email OTP) with ten single-use, bcrypt-hashed recovery codes
- IP rate limiting (10 / 5 min) and per-account lockout with automatic expiry
- Mandatory email verification before first login (24-hour token expiry)
Verification
- Auth package covered by Go unit tests (go test ./pkg/auth/...).
- Deployment checklist verifies rate limiting, MFA, and account lockout before release.
- Tokens are excluded from logs by design; verbose MFA debug logging is gated for removal before production.
Operations
Observability
- Failed-login counters feed the lockout decision per account
- Session table provides an auditable record of active sessions and last activity
- Rate-limit responses return 429 with a retry-after hint
Incident Response
- Compromised account: revoke all sessions (logout-all) and force a password + MFA reset.
- Suspected token theft: the 4-hour inactivity window and hashed storage bound the exposure; rotate the JWT secret to invalidate all sessions if needed.
- Revoke a single session or all sessions for a user from the sessions table
- Reset a locked account by clearing the failed-attempt counter and locked-until timestamp
Cost Controls
- In-memory rate limiting avoids a database round-trip on every login attempt
- Background cleanup keeps the sessions table from growing unbounded
Results
Outcomes
- Defense-in-depth: A stolen password alone is insufficient — it still has to clear MFA, lockout, and rate limiting before a session is issued.
- Containment: A database leak exposes only bcrypt and SHA-256 hashes; no plaintext passwords or usable session tokens.
- Revocability: Sessions are server-tracked, so they can be revoked on demand and expire on inactivity rather than only on signature expiry.
Tradeoffs
- In-memory rate limiting resets on restart and isn't shared across replicas; a Redis-backed limiter is the next step for horizontal scaling.
- Database-tracked sessions add a lookup per authenticated request in exchange for revocability and inactivity timeout.
- Email OTP lowers the MFA barrier but inherits email's own delivery and security characteristics.
Next Steps
- Move rate-limit and lockout state to a shared store for multi-instance consistency
- Make lockout thresholds and durations configurable per environment
- Evaluate WebAuthn / passkeys as a phishing-resistant MFA option