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
secretsmodule (CSPRNG), notrandom. - 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: Coinbase Interview Guide
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems