Two-Factor Authentication Backup Codes Low-Level Design: Generation, Storage, and Recovery

Two-Factor Authentication Backup Codes: Low-Level Design

When a user loses access to their TOTP authenticator app, backup codes are the last line of defense before a full account recovery flow. A poorly designed backup code system either leaks codes through a breach or locks legitimate users out. This article walks through the complete low-level design: cryptographic generation, secure storage, single-use atomic enforcement, rate limiting, and re-generation semantics.

Why Backup Codes Exist

TOTP (RFC 6238) depends on a shared secret and a synchronized clock. Lose the device, lose access. Backup codes are pre-generated one-time passwords stored offline by the user. Each code is valid exactly once. The system must guarantee that even if an attacker reads the database, they cannot use the codes; and that two concurrent recovery attempts cannot both succeed with the same code.

Cryptographic Generation

Backup codes must come from a CSPRNG (cryptographically secure pseudorandom number generator). Python’s secrets module wraps the OS entropy source:

import secrets
import string
import hashlib
import bcrypt
from typing import List, Tuple

BACKUP_CODE_LENGTH = 10          # characters per code
BACKUP_CODE_COUNT  = 10          # codes issued per generation
CODE_ALPHABET = string.digits    # numeric-only; easier to transcribe

def generate_backup_codes(user_id: int) -> Tuple[List[str], List[str]]:
    """
    Returns (plaintext_codes, hashed_codes).
    plaintext_codes are shown to the user ONCE and never stored.
    hashed_codes are persisted to the database.
    """
    plaintext: List[str] = []
    hashed:    List[str] = []

    for _ in range(BACKUP_CODE_COUNT):
        raw = secrets.token_bytes(8)
        # Format as a 10-digit numeric string for readability
        code = str(int.from_bytes(raw, 'big') % (10 ** BACKUP_CODE_LENGTH)).zfill(BACKUP_CODE_LENGTH)
        # Split into two groups: 12345-67890
        plaintext.append(f"{code[:5]}-{code[5:]}")
        # bcrypt each code individually; cost factor 10
        hashed.append(bcrypt.hashpw(code.encode(), bcrypt.gensalt(rounds=10)).decode())

    return plaintext, hashed

Hashing with bcrypt (rather than SHA-256) is deliberate: bcrypt is slow by design, making brute-force of a leaked hash table expensive. Each code gets its own salt, so identical codes across users produce different hashes.

SQL Schema

Two tables are required: one for active codes and one for the audit trail.

-- Active backup codes
CREATE TABLE backup_code (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT      NOT NULL,
    code_hash   TEXT        NOT NULL,          -- bcrypt hash of the raw code
    used_at     TIMESTAMPTZ DEFAULT NULL,      -- NULL = unused
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    generation  INT         NOT NULL DEFAULT 1 -- increments on each re-issue
);

CREATE INDEX idx_backup_code_user ON backup_code (user_id, used_at)
    WHERE used_at IS NULL;                     -- partial index: only unused codes

-- Immutable audit trail
CREATE TABLE backup_code_audit (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT      NOT NULL,
    event       TEXT        NOT NULL,          -- 'generated','used','invalidated','failed_attempt'
    ip_address  INET,
    user_agent  TEXT,
    code_id     BIGINT REFERENCES backup_code(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_bca_user_time ON backup_code_audit (user_id, created_at DESC);

The partial index on (user_id, used_at) WHERE used_at IS NULL keeps lookups fast; once a code is consumed it falls out of the index automatically.

Verification with Atomic Single-Use Enforcement

The critical invariant: a code may only be consumed once, even under concurrent requests. A SELECT then UPDATE is a TOCTOU race. Use a single atomic UPDATE … RETURNING:

import psycopg2
from datetime import datetime, timezone
from typing import Optional

def verify_backup_code(
    conn,
    user_id: int,
    candidate: str,
    ip: str,
    ua: str
) -> bool:
    """
    Returns True if a valid unused code matched candidate.
    Atomically marks the code used so concurrent calls cannot double-consume.
    """
    # Normalize: strip dashes, lowercase
    raw = candidate.replace('-', '').strip()

    with conn.cursor() as cur:
        # Fetch all unused hashes for this user
        cur.execute(
            """
            SELECT id, code_hash
            FROM backup_code
            WHERE user_id = %s AND used_at IS NULL
            FOR UPDATE SKIP LOCKED
            """,
            (user_id,)
        )
        rows = cur.fetchall()

    matched_id: Optional[int] = None
    for row_id, stored_hash in rows:
        if bcrypt.checkpw(raw.encode(), stored_hash.encode()):
            matched_id = row_id
            break

    with conn.cursor() as cur:
        if matched_id:
            # Atomic mark-used; verify it wasn't consumed between our read and write
            cur.execute(
                """
                UPDATE backup_code
                SET used_at = NOW()
                WHERE id = %s AND used_at IS NULL
                RETURNING id
                """,
                (matched_id,)
            )
            updated = cur.fetchone()
            if updated:
                # Log success
                cur.execute(
                    """INSERT INTO backup_code_audit
                       (user_id, event, ip_address, user_agent, code_id)
                       VALUES (%s, 'used', %s, %s, %s)""",
                    (user_id, ip, ua, matched_id)
                )
                conn.commit()
                return True

        # Log failure
        cur.execute(
            """INSERT INTO backup_code_audit
               (user_id, event, ip_address, user_agent)
               VALUES (%s, 'failed_attempt', %s, %s)""",
            (user_id, ip, ua)
        )
        conn.commit()
    return False

The FOR UPDATE SKIP LOCKED clause prevents another concurrent transaction from reading the same rows simultaneously. The final UPDATE … WHERE used_at IS NULL is the hard gate: if two requests somehow reach the UPDATE simultaneously, only one will get a row back from RETURNING.

Rate Limiting Recovery Attempts

Without rate limiting, an attacker can brute-force 10-digit numeric codes offline if hashes leak, or online if attempts are unrestricted. Apply a sliding-window counter in Redis:

import redis
from datetime import timedelta

RATE_LIMIT_WINDOW  = 3600   # 1 hour in seconds
RATE_LIMIT_MAX     = 5      # attempts per window

def check_rate_limit(redis_client: redis.Redis, user_id: int) -> bool:
    """Returns True if request is allowed, False if rate limited."""
    key = f"backup_code_attempts:{user_id}"
    pipe = redis_client.pipeline()
    pipe.incr(key)
    pipe.expire(key, RATE_LIMIT_WINDOW)
    count, _ = pipe.execute()
    return count <= RATE_LIMIT_MAX

Re-Generation and Invalidation

When a user generates a new set of backup codes all previous codes must be atomically invalidated. Increment the generation column and mark all prior codes used with a sentinel timestamp:

def regenerate_backup_codes(conn, user_id: int) -> List[str]:
    plaintext, hashed = generate_backup_codes(user_id)

    with conn.cursor() as cur:
        # Invalidate all existing unused codes
        cur.execute(
            """
            UPDATE backup_code
            SET used_at = NOW()
            WHERE user_id = %s AND used_at IS NULL
            """,
            (user_id,)
        )
        # Determine new generation number
        cur.execute(
            "SELECT COALESCE(MAX(generation),0)+1 FROM backup_code WHERE user_id=%s",
            (user_id,)
        )
        gen = cur.fetchone()[0]

        # Insert new codes
        for h in hashed:
            cur.execute(
                """INSERT INTO backup_code (user_id, code_hash, generation)
                   VALUES (%s, %s, %s)""",
                (user_id, h, gen)
            )
        cur.execute(
            """INSERT INTO backup_code_audit (user_id, event)
               VALUES (%s, 'generated')""",
            (user_id,)
        )
        conn.commit()

    return plaintext   # shown once, then discarded

Delivery and UX Considerations

Backup codes should be presented in a modal with a “Download” button generating a plain-text file named backup-codes-{username}.txt. Never email codes. The page should warn that codes are shown only once. A remaining-count indicator (e.g., “7 of 10 codes remaining”) helps users know when to regenerate without revealing which specific codes remain.

Security Checklist

  • Codes generated with secrets module (CSPRNG), not random.
  • Each code hashed independently with bcrypt (cost ≥ 10).
  • Plaintext codes never written to logs, never stored in DB.
  • Single-use enforced atomically at the database level.
  • Rate limiting applied per-user before bcrypt comparison.
  • All events (generation, use, failure) written to immutable audit log.
  • Re-generation invalidates all prior codes in the same transaction.
  • Codes are not transmitted via email or SMS.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How many backup codes should a system generate per user?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Ten codes is the industry standard (used by Google, GitHub, and Stripe). It is enough to cover multiple recovery scenarios without giving an attacker too large a pool to brute-force. Codes should be single-use and the user should be prompted to regenerate when fewer than three remain.”
}
},
{
“@type”: “Question”,
“name”: “Should backup codes be hashed with bcrypt or SHA-256?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use bcrypt with a cost factor of 10 or higher. SHA-256 is too fast for this use case: a leaked hash table of 10-digit numeric codes could be fully cracked in seconds with a GPU. bcrypt’s intentional slowness makes brute-force infeasible even after a database breach.”
}
},
{
“@type”: “Question”,
“name”: “How do you prevent two concurrent requests from using the same backup code?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use a single atomic UPDATE … WHERE used_at IS NULL … RETURNING statement. Only the transaction that receives a row back from RETURNING actually consumed the code. Any concurrent request racing to the same UPDATE will get zero rows back and must treat the attempt as failed.”
}
},
{
“@type”: “Question”,
“name”: “What happens to old backup codes when a user regenerates?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “All unused codes from the previous generation are immediately marked as used (invalidated) in the same database transaction that inserts the new codes. This prevents a user from accidentally using a code from a printed sheet they thought was still valid.”
}
}
]
}

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How are backup codes generated securely?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “os.urandom or secrets.token_bytes generates cryptographically random codes; they are bcrypt-hashed before storage so the DB never contains plaintext codes.”
}
},
{
“@type”: “Question”,
“name”: “How is single-use enforcement implemented atomically?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “An UPDATE with WHERE used_at IS NULL and RETURNING ensures only one concurrent request can claim a code; the transaction commits only if one row was updated.”
}
},
{
“@type”: “Question”,
“name”: “How does re-generation invalidate old codes?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “All existing unused codes for the user are marked invalidated (or hard-deleted) in the same transaction that inserts new codes.”
}
},
{
“@type”: “Question”,
“name”: “How are recovery attempt brute-force attacks prevented?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A rate limiter tracks failed attempts per user; after N failures the account triggers a lockout or requires additional identity verification.”
}
}
]
}

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Coinbase Interview Guide

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

Scroll to Top