Authentication & Access

Hardening an Admin Backend — MFA, Rate Limiting, and Lockout

TL;DR: Built the authentication core for the admin backend of a multi-tenant auto-dealer SaaS in Go — bcrypt (cost 12), database-tracked JWT sessions with hashed tokens, dual MFA (TOTP plus email OTP) with single-use recovery codes, IP rate limiting, account lockout, and mandatory email verification. Defense-in-depth so a single weakness isn't a single point of failure.

📋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.
bcrypt cost
12
MFA methods
TOTP + Email
Rate limit
10 / 5 min / IP
Session timeout
4 hr idle

📋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

  1. 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.
  2. 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.
  3. 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.
  4. 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

Threat Model
  • 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