My Cursor + Claude Engineering Workflow: A Real Project Walkthrough
Architecting TOTP Two-Factor Authentication: A Production-Ready Implementation Guide
Current Situation Analysis
The modern development landscape is saturated with claims that AI coding assistants deliver 3x to 5x productivity multipliers. In practice, these numbers rarely survive contact with security-critical infrastructure. When implementing two-factor authentication (2FA), developers quickly discover that AI excels at generating repetitive scaffolding but struggles with cryptographic boundaries, state management, and edge-case error handling.
The core misunderstanding lies in treating AI as a uniform accelerator. Authentication flows are not boilerplate; they are state machines with strict security invariants. A misplaced token expiry, an unencrypted secret, or a missing audit log can turn a productivity win into a compliance failure. The industry overlooks this because most AI demonstrations focus on CRUD endpoints, UI components, or data transformation pipelinesâareas where the cost of failure is low and the pattern repetition is high.
Real-world implementation data tells a different story. Adding TOTP-based 2FA with single-use recovery codes to a production FastAPI application required approximately 4.25 hours with AI assistance, compared to an estimated 9 hours using traditional methods. The time savings were heavily concentrated in database schema generation, service scaffolding, and frontend form logic. The architectural bottlenecksâdesigning the pending authentication state, securing cryptographic material, and validating the verification branchâremained human-dominated. This reveals a critical truth: AI accelerates execution, but humans must guard the security perimeter.
WOW Moment: Key Findings
The following breakdown isolates where AI assistance actually moves the needle versus where manual oversight remains non-negotiable.
| Feature Component | Traditional Implementation | AI-Assisted Implementation | Time Saved | AI Impact Level |
|---|---|---|---|---|
| Architecture & Threat Modeling | 2.0 hours | 0.3 hours | 1.7 hours | High (validation only) |
| Database Schema & Migrations | 0.5 hours | 0.2 hours | 0.3 hours | High |
| TOTP Service & Crypto Layer | 1.0 hours | 0.4 hours | 0.6 hours | Medium |
| Auth Router & State Machine | 1.5 hours | 1.5 hours | 0.0 hours | Low (manual required) |
| Unit & Integration Tests | 1.0 hours | 0.5 hours | 0.5 hours | High |
| Frontend 2FA UI/UX | 3.0 hours | 1.5 hours | 1.5 hours | High |
| Total | ~9.0 hours | ~4.4 hours | ~4.6 hours | ~2.0x net speedup |
This data matters because it forces a shift in how teams integrate AI into security workflows. Instead of asking "Can AI write this?", engineers should ask "Which parts of this feature are cryptographic or stateful?" The architectural and security-critical paths require manual design and line-by-line review. The repetitive, pattern-heavy components can be safely delegated to AI with strict guardrails. Recognizing this split prevents over-reliance and ensures that productivity gains don't compromise system integrity.
Core Solution
Implementing TOTP 2FA requires careful separation of concerns: cryptographic storage, stateless session management, verification logic, and frontend state synchronization. The following architecture uses FastAPI, SQLAlchemy 2.0, and modern Python security practices.
Step 1: Cryptographic Storage Design
TOTP secrets must be reversible for QR code generation and verification, but they cannot be stored in plaintext. We use symmetric encryption (Fernet) for the TOTP secret and one-way hashing (SHA-256) for recovery codes.
# models/account.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship, mapped_column
from cryptography.fernet import Fernet
import hashlib
import secrets
class UserAccount(Base):
__tablename__ = "user_accounts"
id = mapped_column(Integer, primary_key=True)
email = mapped_column(String(255), unique=True, nullable=False)
password_hash = mapped_column(String(255), nullable=False)
# 2FA fields
totp_secret_encrypted = mapped_column(String(255), nullable=True)
is_2fa_enabled = mapped_column(Boolean, default=False)
verified_at = mapped_column(DateTime, nullable=True)
recovery_codes = relationship("RecoveryVault", back_populates="owner", cascade="all, delete-orphan")
class RecoveryVault(Base):
__tablename__ = "recovery_vaults"
id = mapped_column(Integer, primary_key=True)
owner_id = mapped_column(Integer, ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=False)
code_hash = mapped_column(String(64), unique=True, index=True, nullable=False)
is_consumed = mapped_column(Boolean, default=False)
consumed_at = mapped_column(DateTime, nullable=True)
owner = relationship("UserAccount", back_populates="recovery_codes")
Rationale: Fernet provides authenticated encryption with a fixed 32-byte key, making it ideal for reversible secret storage. SHA-256 is chosen for recovery codes because they are 128-bit random strings; bcrypt's computational cost is unnecessary and would slow down verification. The ondelete="CASCADE" ensures orphaned recovery codes are cleaned up automatically.
Step 2: Stateless Pending Authentication
Traditional session-based 2FA flows introduce database lookups during verification, complicating horizontal scaling. A better approach uses a short-lived, stateless JWT containing a 2fa_pending claim.
# services/auth_state.py
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from fastapi import HTTPException, status
class PendingSessionManager:
SECRET_KEY = "your_jwt_signing_key"
ALGORITHM = "HS256"
PENDING_TTL_MINUTES = 5
@classmethod
def create_pending_token(cls, user_id: int) -> str:
payload = {
"sub": str(user_id),
"2fa_pending": True,
"exp": datetime.now(timezone.utc) + timedelta(minutes=cls.PENDING_TTL_MINUTES),
"iat": datetime.now(timezone.utc)
}
return jwt.encode(payload, cls.SECRET_KEY, algorithm=cls.ALGORITHM)
@classmethod
def validate_pending_token(cls, token: str) -> dict:
try:
payload = jwt.decode(token, cls.SECRET_KEY, algorithms=[cls.ALGORITHM])
if not payload.get("2fa_pending"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token claim")
return payload
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired or malformed")
Rationale: The pending token acts as a cryptographic handshake. It proves the user passed password authentication but hasn't completed 2FA. The 5-minute TTL limits the attack window, and the stateless design removes database pressure during the verification step.
Step 3: TOTP Verification & Recovery Service
The verification layer handles time-based code validation, clock drift tolerance, and recovery code consumption.
# services/two_factor.py
import pyotp
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models.account import UserAccount, RecoveryVault
import hashlib
import secrets
import base64
class TwoFactorAuthenticator:
FERNET_KEY = Fernet.generate_key() # In production, load from secure vault
RECOVERY_CODE_LENGTH = 12
RECOVERY_CODE_COUNT = 10
@classmethod
def encrypt_secret(cls, raw_secret: str) -> str:
fernet = Fernet(cls.FERNET_KEY)
return fernet.encrypt(raw_secret.encode()).decode()
@classmethod
def decrypt_secret(cls, encrypted_secret: str) -> str:
fernet = Fernet(cls.FERNET_KEY)
return fernet.decrypt(encrypted_secret.encode()).decode()
@classmethod
def generate_totp_uri(cls, email: str, secret: str) -> str:
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name="CitizenApp")
@classmethod
async def verify_totp_code(cls, encrypted_secret: str, user_code: str) -> bool:
raw_secret = cls.decrypt_secret(encrypted_secret)
totp = pyotp.TOTP(raw_secret)
# valid_window=1 accounts for ±30s clock drift on mobile devices
return totp.verify(user_code, valid_window=1) is not None
@classmethod
async def generate_recovery_codes(cls, db: AsyncSession, user_id: int) -> list[str]:
raw_codes = [secrets.token_hex(6) for _ in range(cls.RECOVERY_CODE_COUNT)]
hashed_codes = [hashlib.sha256(code.encode()).hexdigest() for code in raw_codes]
vault_entries = [
RecoveryVault(owner_id=user_id, code_hash=hc) for hc in hashed_codes
]
db.add_all(vault_entries)
await db.commit()
return raw_codes
@classmethod
async def verify_recovery_code(cls, db: AsyncSession, user_id: int, user_code: str) -> bool:
target_hash = hashlib.sha256(user_code.encode()).hexdigest()
stmt = select(RecoveryVault).where(
RecoveryVault.owner_id == user_id,
RecoveryVault.code_hash == target_hash,
RecoveryVault.is_consumed == False
)
result = await db.execute(stmt)
vault_entry = result.scalar_one_or_none()
if not vault_entry:
return False
vault_entry.is_consumed = True
vault_entry.consumed_at = datetime.now(timezone.utc)
await db.commit()
return True
Rationale: The valid_window=1 parameter is critical. Mobile TOTP apps often sync clocks imperfectly; without a window, legitimate users face constant verification failures. Recovery codes are hashed immediately upon generation and marked consumed atomically to prevent replay attacks.
Step 4: Auth Router Integration
The login endpoint branches based on 2FA status. The verification endpoint accepts either a TOTP code or a recovery code.
# routes/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from services.auth_state import PendingSessionManager
from services.two_factor import TwoFactorAuthenticator
from services.database import get_async_session
from schemas.auth import LoginRequest, Verify2FARequest
from services.auth import authenticate_credentials, issue_full_tokens
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post("/login")
async def initiate_login(req: LoginRequest, db: AsyncSession = Depends(get_async_session)):
user = await authenticate_credentials(db, req.email, req.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.is_2fa_enabled:
pending = PendingSessionManager.create_pending_token(user.id)
return {"requires_2fa": True, "pending_token": pending}
return await issue_full_tokens(user)
@router.post("/verify-2fa")
async def complete_2fa(req: Verify2FARequest, db: AsyncSession = Depends(get_async_session)):
token_data = PendingSessionManager.validate_pending_token(req.pending_token)
user_id = int(token_data["sub"])
# Attempt TOTP verification first
totp_ok = await TwoFactorAuthenticator.verify_totp_code(
user.totp_secret_encrypted, req.code
)
# Fallback to recovery code if TOTP fails
recovery_ok = await TwoFactorAuthenticator.verify_recovery_code(db, user_id, req.code)
if not (totp_ok or recovery_ok):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code")
# Log authentication method for audit trail
method = "totp" if totp_ok else "recovery_code"
await audit_logger.record_auth_event(user_id, method)
return await issue_full_tokens(user)
Rationale: The or short-circuit is intentional but requires explicit audit logging. Without it, security teams cannot distinguish between TOTP and recovery code usage, which is vital for detecting compromised authenticator apps.
Pitfall Guide
1. Ignoring Clock Drift in TOTP Verification
Explanation: TOTP relies on synchronized time. Mobile devices, especially those on cellular networks or with manual clock settings, frequently drift by 15-45 seconds.
Fix: Always configure valid_window=1 (or 2 for high-latency environments) in pyotp.TOTP.verify(). This allows ±1 or ±2 time steps (30s each) without compromising security.
2. Storing TOTP Secrets in Plaintext
Explanation: If the database is breached, plaintext TOTP secrets allow attackers to generate valid codes indefinitely, completely bypassing 2FA. Fix: Use symmetric encryption (Fernet, AES-GCM) for TOTP secrets. Store the encryption key in a hardware security module (HSM) or cloud KMS, never in environment variables or code.
3. Unsafe Alembic Migration Generation
Explanation: Auto-generated migrations can silently drop columns, rename tables incorrectly, or apply incompatible type changes in production.
Fix: Always run alembic revision --autogenerate in a staging environment first. Review the generated SQL manually. Use --sql to inspect the exact DDL before applying.
4. Missing Authentication Method Auditing
Explanation: Recovery codes are often used when a user loses their authenticator app. Without logging which method succeeded, security teams cannot detect anomalous patterns (e.g., frequent recovery code usage indicating account takeover).
Fix: Implement an audit trail that records auth_method, ip_address, user_agent, and timestamp for every 2FA verification attempt.
5. Over-Delegating Auth Flow to AI
Explanation: AI models optimize for syntactic correctness, not security invariants. They may generate plausible-looking auth routers that lack rate limiting, proper error masking, or token invalidation logic.
Fix: Treat AI as a drafting tool. Manually write the auth router, enforce rate limits on /verify-2fa, and validate token expiry logic before deployment.
6. Inadequate Recovery Code Rotation Strategy
Explanation: Users who never rotate recovery codes leave a static attack surface. If one code is leaked, it remains valid indefinitely. Fix: Implement a "regenerate" endpoint that invalidates all existing codes and issues a fresh set. Require password re-authentication before regeneration.
7. Frontend State Desynchronization
Explanation: React components may cache the pending_token incorrectly or fail to clear it on logout, causing stale 2FA prompts or token reuse vulnerabilities.
Fix: Store the pending token in memory or secure HTTP-only cookies. Clear it explicitly on route change, logout, and verification success. Use React Query or SWR to invalidate auth caches.
Production Bundle
Action Checklist
- Schema Design: Add encrypted TOTP secret field and hashed recovery code table with cascade delete
- Crypto Configuration: Initialize Fernet key in secure vault, configure SHA-256 for recovery codes
- Pending Token: Implement 5-minute TTL JWT with
2fa_pendingclaim and strict validation - Verification Logic: Integrate
pyotpwithvalid_window=1, implement atomic recovery code consumption - Audit Trail: Log authentication method, IP, and timestamp for every 2FA attempt
- Rate Limiting: Apply strict throttling (e.g., 5 attempts/minute) to
/verify-2faendpoint - Frontend State: Clear pending tokens on logout, handle QR fallback, enforce keyboard navigation
- Testing: Mock
pyotpverification, test expired tokens, validate recovery code consumption flags
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| CRUD endpoints, UI forms, test fixtures | AI-assisted generation | High pattern repetition, low security risk | Low (review overhead minimal) |
| Auth router, token lifecycle, session state | Manual implementation | Stateful logic requires strict security invariants | Medium (architectural time investment) |
| Cryptographic storage, key management | Manual + HSM/KMS integration | AI cannot securely handle key rotation or encryption boundaries | High (infrastructure cost) |
| Database migrations, schema changes | AI draft + manual SQL review | Auto-generation risks silent data loss or type mismatches | Low-Medium |
| Error handling, edge cases, audit logs | Manual implementation | AI defaults to happy paths; misses security telemetry | Low |
Configuration Template
Project-level IDE rules to enforce secure code generation patterns:
{
"rules": [
"Always use SQLAlchemy 2.0 mapped_column syntax with explicit nullable constraints",
"Enforce async/await for all database operations; never mix sync and async sessions",
"Use Pydantic v2 model_validator for input validation; avoid deprecated root_validator",
"Encrypt all PII and cryptographic secrets using Fernet or AES-GCM; never store plaintext",
"Include explicit error handling for database operations and token validation failures",
"Add rate limiting decorators to all authentication and verification endpoints",
"Log authentication method, IP, and timestamp for every 2FA verification attempt",
"Use valid_window=1 for pyotp.TOTP.verify() to accommodate mobile clock drift"
]
}
Quick Start Guide
- Initialize Cryptographic Layer: Generate a Fernet key, store it in your cloud KMS or HSM, and configure the
TwoFactorAuthenticatorservice to load it at startup. - Apply Database Migrations: Run
alembic revision --autogenerate -m "add_totp_2fa", review the generated SQL for column types and indexes, then apply to your database. - Wire Auth Endpoints: Replace your existing login handler with the pending token branch, implement the
/verify-2faroute, and attach rate limiting middleware. - Deploy & Validate: Run the test suite, verify TOTP generation with a mobile authenticator app, test recovery code consumption, and confirm audit logs capture the authentication method.
Mid-Year Sale â Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register â Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
