Build a 2FA System from Scratch

Implement HOTP and TOTP from RFC primitives and wire into a FastAPI demo.

Introduction

30 Second Summary

Every time you open your banking app and type in a 6-digit code from your phone, a cryptographic algorithm is running behind the scenes. Most developers treat that algorithm as a black box, trusting a library they never looked inside.

In this project, you will implement the TOTP two-factor authentication algorithm from raw cryptographic primitives. You will validate it against official RFC test vectors, then build a FastAPI web demo where Google Authenticator on your phone verifies login codes using YOUR implementation.

What You'll Build

You will register a user on your local web app, scan a QR code with your phone, and log in by typing a 6-digit code that cryptographic code YOU wrote validates on the server.

By the end of this project, you'll have:

  • A crypto library implementing HOTP and TOTP from scratch, passing all 16 official RFC test vectors and proving YOUR code generates the same output as every authenticator app in the world.
  • A web app where you register a user, scan a QR code with Google Authenticator, and log in with a 6-digit code that your crypto code validates server-side.
  • Replay prevention that rejects reused codes on the spot, blocking the attack most authentication tutorials forget to address.
  • Secret Mission: Rate limiting that locks accounts after failed attempts, making brute-force attacks on the 6-digit code space infeasible.

Are there any prerequisites?

You should be comfortable with Python fundamentals: functions, bytes, and bitwise operations. No web framework experience is needed.

Before We Start

Before we write any code, let's lock in what you're about to build and why it matters. You're implementing a two-factor authentication system from raw cryptographic primitives, not pip-installing someone else's library.

Implement HOTP (RFC 4226)

Your project skeleton is ready and pytest is responding. Now it is time to write the cryptographic core that everything else in this project builds on.

HOTP is three operations chained together: compute an HMAC-SHA1 hash over a counter, dynamically truncate that hash down to a 31-bit integer, and modulo the result into a 6-digit code. Getting these three operations right means your implementation produces the exact same codes as every compliant authenticator app in the world.

In this step, get ready to:
  • Implement the hotp() function using Python's stdlib.
  • Write all 10 RFC 4226 test vectors as a parametrized test.
  • Run the tests to prove RFC compliance.
Implement the hotp() function

RFC 4226 defines HOTP (HMAC-Based One-Time Password) as a three-stage pipeline. You feed a shared secret and a counter into HMAC-SHA1, extract 4 specific bytes from the 20-byte hash output (dynamic truncation), then reduce the result to a human-friendly 6-digit code.

  • Open totpcore/hotp.py in VS Code and replace the empty file with the following implementation:
import hmac
import struct


def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
    """
    Generate an HOTP code per RFC 4226.

    Args:
        secret: The shared secret as raw bytes.
        counter: The 8-byte counter value.
        digits: Number of digits in the OTP (default 6).

    Returns:
        A zero-padded string of `digits` length.
    """
    counter_bytes = struct.pack(">Q", counter)
    hmac_digest = hmac.new(secret, counter_bytes, "sha1").digest()

    offset = hmac_digest[-1] & 0x0F

    truncated = (
        ((hmac_digest[offset] & 0x7F) << 24)
        | ((hmac_digest[offset + 1] & 0xFF) << 16)
        | ((hmac_digest[offset + 2] & 0xFF) << 8)
        | (hmac_digest[offset + 3] & 0xFF)
    )

    code = truncated % (10**digits)
    return str(code).zfill(digits)

What does this code do?

  • struct.pack(">Q", counter) converts the counter into an 8-byte big-endian integer. This is the exact format the RFC requires.
  • hmac.new(secret, counter_bytes, "sha1").digest() computes the HMAC-SHA1 hash, producing a 20-byte digest.
  • Dynamic truncation: the last nibble of the digest (& 0x0F) gives an offset (0-15). You extract 4 bytes starting at that offset and mask the high bit with 0x7F to get a 31-bit unsigned integer. This prevents signed/unsigned disagreement across implementations.
  • Modulo 10**digits reduces the 31-bit number to 6 digits, and zfill(digits) ensures leading zeros are preserved (e.g. 007123 stays 6 characters).
  • Save totpcore/hotp.py.
  • Verify the function works by running this quick check in your terminal:
python -c "from totpcore.hotp import hotp; print(hotp(b'12345678901234567890', 0))"

You should see 755224 printed. That is the first RFC 4226 test vector for counter 0.

Seeing an ImportError?

  • Make sure you are running the command from inside the totp-2fa/ directory (the parent of totpcore/).
  • Confirm your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.

Help me debug an ImportError when importing from totpcore

✔️ Awesome, I've got everything!

Great. Make sure totpcore/hotp.py is saved and move on to writing the test vectors.

ⓧ I'd like to double check the full code

import hmac
import struct


def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
    """
    Generate an HOTP code per RFC 4226.

    Args:
        secret: The shared secret as raw bytes.
        counter: The 8-byte counter value.
        digits: Number of digits in the OTP (default 6).

    Returns:
        A zero-padded string of `digits` length.
    """
    counter_bytes = struct.pack(">Q", counter)
    hmac_digest = hmac.new(secret, counter_bytes, "sha1").digest()

    offset = hmac_digest[-1] & 0x0F

    truncated = (
        ((hmac_digest[offset] & 0x7F) << 24)
        | ((hmac_digest[offset + 1] & 0xFF) << 16)
        | ((hmac_digest[offset + 2] & 0xFF) << 8)
        | (hmac_digest[offset + 3] & 0xFF)
    )

    code = truncated % (10**digits)
    return str(code).zfill(digits)
Write the RFC 4226 test vectors

RFC 4226 Appendix D publishes 10 known-correct outputs for counters 0 through 9. These are the gold standard. If your function produces these exact values, it is interoperable with every compliant HOTP implementation on earth.

  • Open tests/test_hotp.py and replace the placeholder test with the following parametrized test:
import pytest

from totpcore.hotp import hotp

RFC4226_SECRET = b"12345678901234567890"

RFC4226_VECTORS = [
    (0, "755224"),
    (1, "287082"),
    (2, "359152"),
    (3, "969429"),
    (4, "338314"),
    (5, "254676"),
    (6, "287922"),
    (7, "162583"),
    (8, "399871"),
    (9, "520489"),
]


@pytest.mark.parametrize("counter,expected", RFC4226_VECTORS)
def test_rfc4226_appendix_d(counter, expected):
    """Validate HOTP against all 10 RFC 4226 Appendix D test vectors."""
    result = hotp(RFC4226_SECRET, counter, digits=6)
    assert result == expected, f"Counter {counter}: expected {expected}, got {result}"

What does this test do?

The secret b"12345678901234567890" is the 20-byte ASCII string specified in RFC 4226 Appendix D.

@pytest.mark.parametrize runs the same test function 10 times, once for each (counter, expected) pair. If any single vector fails, pytest reports exactly which counter produced the wrong code.

  • Save tests/test_hotp.py.

✔️ Awesome, I've got everything!

Great. Make sure tests/test_hotp.py is saved and move on to running the tests.

ⓧ I'd like to double check the full code

import pytest

from totpcore.hotp import hotp

RFC4226_SECRET = b"12345678901234567890"

RFC4226_VECTORS = [
    (0, "755224"),
    (1, "287082"),
    (2, "359152"),
    (3, "969429"),
    (4, "338314"),
    (5, "254676"),
    (6, "287922"),
    (7, "162583"),
    (8, "399871"),
    (9, "520489"),
]


@pytest.mark.parametrize("counter,expected", RFC4226_VECTORS)
def test_rfc4226_appendix_d(counter, expected):
    """Validate HOTP against all 10 RFC 4226 Appendix D test vectors."""
    result = hotp(RFC4226_SECRET, counter, digits=6)
    assert result == expected, f"Counter {counter}: expected {expected}, got {result}"
Run the tests and confirm RFC compliance

Before you run this, think about what you expect to happen. You wrote the algorithm from the RFC spec and the test vectors straight from Appendix D. Do you think all 10 will pass on the first try?

  • Run the HOTP test suite with verbose output:
pytest tests/test_hotp.py -v

You should see 10 lines of green, each showing PASSED for counters 0 through 9.

What do these results prove?

Each passing test means your implementation produces the same code as the RFC's reference table. Counter 0 produces 755224, counter 9 produces 520489. These values were derived from the intermediate truncated hex 4c93cf18 (for counter 0) that the RFC publishes.

Every authenticator app that passes these same vectors is interoperable with yours. Your hotp() function is now a proven-correct building block.

Seeing test failures?

  • Check that the secret is exactly b"12345678901234567890" (20 ASCII bytes, not base32-encoded).
  • Confirm the struct.pack format string is ">Q" (big-endian unsigned 8-byte). A lowercase q means signed, which can produce wrong results for large counters.
  • Verify the high-bit mask is 0x7F on the FIRST byte at the offset, not on all four bytes.

Help me debug a failing HOTP test vector

Your HOTP implementation is now proven correct against the official RFC test vectors. Next up, you will build TOTP on top of this function by deriving the counter from the current time.

Implement TOTP (RFC 6238)

Your HOTP implementation produces correct codes for any counter value. TOTP is what Google Authenticator actually runs on your phone.

The trick is simple: instead of requiring a shared counter, TOTP derives the counter from the current Unix time divided by a 30-second step. Your existing hotp() function does all the heavy lifting. This step also adds verify_totp() with a time-window tolerance and returns the matched counter, which you will need for replay prevention later.

In this step, get ready to:
  • Implement totp() and verify_totp() by reusing your HOTP function with time-derived counters.
  • Add generate_secret() and provisioning_uri() to make your library ready for QR code enrollment.
  • Prove RFC compliance with 6 official TOTP test vectors and cross-validate against pyotp.
Implement the totp() function

TOTP reuses HOTP entirely. The only new logic is computing the counter from a Unix timestamp: divide the time by the period (30 seconds) and discard the fractional part.

  • Open totpcore/totp.py in VS Code and add the imports, then the totp() function:
import hmac as hmac_module
import time as time_module

from .hotp import hotp


def totp(
    secret: bytes,
    digits: int = 6,
    period: int = 30,
    time: float | None = None,
) -> str:
    """
    Generate a TOTP code per RFC 6238.

    Args:
        secret: The shared secret as raw bytes.
        digits: Number of digits in the OTP (default 6).
        period: Time step in seconds (default 30).
        time: Unix timestamp to use (defaults to current time).

    Returns:
        A zero-padded string of `digits` length.
    """
    if time is None:
        time = time_module.time()

    counter = int(time) // period
    return hotp(secret, counter, digits=digits)

What does this code do?

  • import hmac as hmac_module aliases the module so the name hmac does not collide with any variable names later.
  • The optional time parameter lets you inject a fixed timestamp for deterministic testing. Without it, the function uses the real clock.
  • int(time) // period is the entire TOTP derivation: floor-divide Unix seconds by 30 to get the counter, then delegate to hotp().
  • Save totpcore/totp.py.
  • Verify it works by running this quick check against the first RFC 6238 test vector (time=59, expecting the 8-digit code 94287082):
python -c "from totpcore.totp import totp; print(totp(b'12345678901234567890', digits=8, time=59))"

You should see 94287082 printed to the terminal.

Seeing an ImportError?

  • Make sure you are running the command from the totp-2fa project root, not from inside the totpcore/ folder.
  • Confirm your virtual environment is activated (you should see (.venv) in your prompt).

Still stuck? Why can't Python find my totpcore module?

Implement the verify_totp() function

A real authenticator app and your server's clock might be a few seconds out of sync. verify_totp() compensates by checking the submitted code against the previous, current, and next time step.

  • In totpcore/totp.py, add the verify_totp() function below your existing totp() function:
def verify_totp(
    secret: bytes,
    code: str,
    digits: int = 6,
    period: int = 30,
    time: float | None = None,
    window: int = 1,
) -> int:
    """
    Verify a TOTP code within a time window.

    Checks the code against time steps [T - window, T + window].

    Returns:
        The time step (counter) that matched.

    Raises:
        ValueError: If the code does not match any time step in the window.
    """
    if time is None:
        time = time_module.time()

    current_counter = int(time) // period

    for offset in range(-window, window + 1):
        counter = current_counter + offset
        expected = hotp(secret, counter, digits=digits)
        if hmac_module.compare_digest(expected, code):
            return counter

    raise ValueError("Invalid TOTP code")

What does this code do?

  • The window parameter controls tolerance. With window=1, the function checks counters T-1, T, and T+1 (covering 90 seconds of drift).
  • hmac_module.compare_digest() performs constant-time string comparison, preventing timing attacks that could leak which digits are correct.
  • Returning the matched counter (not just True/False) is critical. You will use this value for replay prevention in a later step.
  • Save totpcore/totp.py.
  • Test verification by generating a code and immediately verifying it:
python -c "from totpcore.totp import totp, verify_totp; code = totp(b'12345678901234567890', digits=6, time=1234567890.0); print('Code:', code); print('Counter:', verify_totp(b'12345678901234567890', code, digits=6, time=1234567890.0))"

You should see a 6-digit code followed by the counter value 41152263 (which is 1234567890 // 30).

Seeing "Invalid TOTP code"?

  • Make sure you have the import hmac as hmac_module at the top of your file. The compare_digest function lives on that module.
  • Confirm the from .hotp import hotp import is present. Without it, verify_totp cannot generate the expected codes.

Need more help? Help me debug my verify_totp function

Add generate_secret() and provisioning_uri() to the package

Your crypto functions work, but a user enrolling in 2FA needs two things: a random secret and a URI that encodes it into a scannable QR code. These utility functions complete your library's public API.

  • Open totpcore/__init__.py and replace its contents with:
import base64
import os
from urllib.parse import quote

from .hotp import hotp
from .totp import totp, verify_totp


def generate_secret(length: int = 20) -> str:
    """Generate a random base32-encoded secret of the given byte length."""
    random_bytes = os.urandom(length)
    return base64.b32encode(random_bytes).decode("ascii")


def provisioning_uri(
    secret: str,
    account: str,
    issuer: str,
    digits: int = 6,
    period: int = 30,
) -> str:
    """Generate an otpauth:// URI for QR code provisioning."""
    label = f"{quote(issuer)}:{quote(account)}"
    params = f"secret={secret}&issuer={quote(issuer)}&digits={digits}&period={period}"
    return f"otpauth://totp/{label}?{params}"

What does this code do?

  • generate_secret() creates 20 random bytes (160 bits, matching the SHA-1 key length) and base32-encodes them. Base32 is the standard encoding authenticator apps expect.
  • provisioning_uri() formats the otpauth:// URI that Google Authenticator reads from a QR code. The format is standardized so any compliant app can parse it.
  • The re-exports (from .hotp import hotp etc.) make the full library importable from a single import totpcore statement.
  • Save totpcore/__init__.py.
  • Confirm the library is importable and generates secrets by running:
python -c "import totpcore; s = totpcore.generate_secret(); print('Secret:', s); print('URI:', totpcore.provisioning_uri(s, 'user@example.com', 'Demo'))"

You should see a base32 string (32 uppercase characters) and a full otpauth://totp/... URI.

Seeing a circular import error?

  • Ensure totpcore/__init__.py uses relative imports (from .hotp and from .totp), not absolute ones.
  • Run the command from the totp-2fa root directory, not from inside totpcore/.

Still stuck? Help me fix my totpcore package imports

Write the RFC 6238 test vectors and cross-validation

The RFC 6238 appendix publishes 6 test vectors for SHA-1. These use 8-digit codes (not 6), which is what the RFC specifies for its test cases. You will also cross-validate your output against pyotp to prove ecosystem interoperability.

  • Open tests/test_totp.py and add the imports, test secret, vector list, and parametrized test:
import pytest

from totpcore.totp import totp, verify_totp

SHA1_SECRET = b"12345678901234567890"

RFC6238_SHA1_VECTORS = [
    (59, "94287082"),
    (1111111109, "07081804"),
    (1111111111, "14050471"),
    (1234567890, "89005924"),
    (2000000000, "69279037"),
    (20000000000, "65353130"),
]


@pytest.mark.parametrize("timestamp,expected", RFC6238_SHA1_VECTORS)
def test_rfc6238_sha1(timestamp, expected):
    """Validate TOTP SHA-1 against RFC 6238 Appendix B (8-digit codes)."""
    result = totp(SHA1_SECRET, digits=8, period=30, time=timestamp)
    assert result == expected, f"Time {timestamp}: expected {expected}, got {result}"

Why 8 digits instead of 6?

The RFC 6238 test vectors are all 8-digit codes. Google Authenticator uses 6-digit codes in practice, but the official spec tests with 8. Your implementation supports configurable digit counts, so both work.

  • Save tests/test_totp.py and run the TOTP tests:
pytest tests/test_totp.py -v

You should see all 6 parametrized test cases pass.

Seeing test failures?

  • If you see wrong codes, double-check that your totp() function passes digits=digits through to hotp(). Forgetting this defaults every call to 6 digits.
  • Confirm the counter derivation uses int(time) // period (integer division), not time / period (float division).

Need help? Help me debug my failing TOTP test vectors

  • Now add the verification tests and cross-validation test below the existing code in tests/test_totp.py:
def test_verify_totp_valid():
    """Verify that a code for the current time step is accepted."""
    import time as time_module

    now = time_module.time()
    code = totp(SHA1_SECRET, digits=6, period=30, time=now)
    counter = verify_totp(SHA1_SECRET, code, digits=6, period=30, time=now)
    assert counter == int(now) // 30


def test_verify_totp_window():
    """Verify that a code from the previous time step is accepted."""
    timestamp = 1234567890.0
    prev_step_time = timestamp - 30
    code = totp(SHA1_SECRET, digits=6, period=30, time=prev_step_time)
    counter = verify_totp(SHA1_SECRET, code, digits=6, period=30, time=timestamp, window=1)
    assert counter == int(prev_step_time) // 30


def test_verify_totp_invalid():
    """Verify that an incorrect code raises ValueError."""
    with pytest.raises(ValueError, match="Invalid TOTP code"):
        verify_totp(SHA1_SECRET, "000000", digits=6, period=30, time=1234567890.0)

What do these tests prove?

  • test_verify_totp_valid confirms a code for the exact current time step is accepted.
  • test_verify_totp_window proves that a code from the previous 30-second window still passes (clock drift tolerance).
  • test_verify_totp_invalid confirms a bogus code raises ValueError as expected.
  • Finally, add the pyotp cross-validation test at the bottom of tests/test_totp.py:
def test_cross_validate_with_pyotp():
    """Cross-validate against pyotp to prove interoperability."""
    import base64
    import pyotp

    secret_bytes = SHA1_SECRET
    secret_b32 = base64.b32encode(secret_bytes).decode("ascii")
    timestamp = 1234567890.0

    our_code = totp(secret_bytes, digits=6, period=30, time=timestamp)
    pyotp_code = pyotp.TOTP(secret_b32).at(timestamp)

    assert our_code == pyotp_code, f"Interop failure: ours={our_code}, pyotp={pyotp_code}"

Why cross-validate against pyotp?

pyotp is the most widely used Python OTP library. If your hand-rolled implementation produces the same code as pyotp for the same secret and timestamp, you have proven interoperability with the broader ecosystem. Your code is not just theoretically correct. It is practically compatible.

  • Save tests/test_totp.py.

Before you run the full suite, predict: how many total tests do you expect to pass across both test files?

  • Run the complete test suite:
pytest tests/ -v

You should see all 10 HOTP vectors, 6 TOTP vectors, plus the 4 additional tests pass (20 total).

Cross-validation test failing?

  • Confirm pyotp is installed in your virtual environment. Run pip list | grep pyotp to check.
  • Make sure you are encoding SHA1_SECRET with base64.b32encode() before passing it to pyotp.TOTP(). pyotp expects a base32 string, not raw bytes.

Still stuck? Help me debug the pyotp cross-validation test

✔️ Awesome, I've got everything!

Great. Double check you have saved both totpcore/totp.py, totpcore/__init__.py, and tests/test_totp.py.

ⓧ I'd like to double check the full code

Here is the complete totpcore/__init__.py:

import base64
import os
from urllib.parse import quote

from .hotp import hotp
from .totp import totp, verify_totp


def generate_secret(length: int = 20) -> str:
    """Generate a random base32-encoded secret of the given byte length."""
    random_bytes = os.urandom(length)
    return base64.b32encode(random_bytes).decode("ascii")


def provisioning_uri(
    secret: str,
    account: str,
    issuer: str,
    digits: int = 6,
    period: int = 30,
) -> str:
    """Generate an otpauth:// URI for QR code provisioning."""
    label = f"{quote(issuer)}:{quote(account)}"
    params = f"secret={secret}&issuer={quote(issuer)}&digits={digits}&period={period}"
    return f"otpauth://totp/{label}?{params}"

For totpcore/totp.py, confirm you have both functions in order: the totp() function (imports at top, then the function accepting secret/digits/period/time) followed by verify_totp() (accepting secret/code/digits/period/time/window). Scroll up to the two code chunks you pasted earlier and confirm they are both present.

For tests/test_totp.py, confirm you have: the imports and RFC6238_SHA1_VECTORS list at the top, then test_rfc6238_sha1, then the three verify tests (test_verify_totp_valid, test_verify_totp_window, test_verify_totp_invalid), and finally test_cross_validate_with_pyotp at the bottom.

Your TOTP library is complete and proven correct against the official RFC vectors and the ecosystem's most popular implementation. Next up, you will wire this into a FastAPI web app with QR code enrollment so you can scan a code with your phone and see YOUR crypto in action.

Build FastAPI 2FA Enrollment with QR Code

Your TOTP library passes all 16 RFC test vectors, but nobody can use it yet. Right now it only lives inside a test suite.

In this step you will build the web demo that makes your crypto real: user registration with hashed passwords, session-based login, and a 2FA enrollment page that generates a QR code. The payoff comes when you scan that QR code with Google Authenticator and see YOUR algorithm producing valid 6-digit codes on your phone.

In this step, get ready to:
  • Build user registration with PBKDF2-hashed passwords and cookie-based sessions.
  • Create a 2FA enrollment flow that renders a QR code scannable by Google Authenticator.
  • Confirm enrollment by verifying the first TOTP code from your phone.
Build the registration and login flow

Your minimal webapp/app.py currently returns a JSON placeholder. You will replace it with a full FastAPI application that handles user registration, password hashing, session management, and login.

  • Open webapp/app.py in VS Code and delete all existing content.
  • Replace it with the following imports, app setup, and in-memory storage:
import base64
import hashlib
import io
import os
import secrets
import time

import qrcode
from fastapi import FastAPI, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

import totpcore

app = FastAPI()
templates = Jinja2Templates(directory="webapp/templates")

# In-memory storage for users and active sessions
users: dict[str, dict] = {}
sessions: dict[str, str] = {}

What does this code do?

This sets up the foundation for the entire web app. Jinja2Templates tells FastAPI where to find HTML template files.

The two dicts (users and sessions) act as an in-memory database. This means all data resets when the server restarts, which is fine for a local demo.

  • Below the storage dicts, add the password hashing and session helper functions:
def hash_password(password: str, salt: bytes) -> bytes:
    # PBKDF2-SHA256 with 100k iterations
    return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100_000)


def get_current_user(request: Request) -> dict | None:
    # Read session token from cookie, look up username
    session_token = request.cookies.get("session")
    if not session_token or session_token not in sessions:
        return None
    username = sessions[session_token]
    return users.get(username)

What does this code do?

hash_password() uses PBKDF2 (100,000 iterations of SHA-256) so passwords are never stored in plain text. The random salt ensures identical passwords produce different hashes.

get_current_user() reads the session cookie from the request, looks it up in the sessions dict, and returns the matching user record.

  • Below the helpers, add the home route and registration routes:
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    # Redirect logged-in users to dashboard, others to login
    user = get_current_user(request)
    if user:
        return RedirectResponse("/dashboard", status_code=302)
    return RedirectResponse("/login", status_code=302)


@app.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
    return templates.TemplateResponse(request=request, name="register.html", context={})


@app.post("/register")
async def register_submit(username: str = Form(), password: str = Form()):
    if username in users:
        return RedirectResponse("/register?error=exists", status_code=302)
    # Generate random salt and store hashed password
    salt = os.urandom(16)
    users[username] = {
        "password_hash": hash_password(password, salt),
        "salt": salt,
        "totp_secret": None,
        "totp_enabled": False,
        "last_used_counter": 0,
    }
    return RedirectResponse("/login?msg=registered", status_code=302)

What does this code do?

The home route (/) acts as a traffic director: logged-in users go to their dashboard, everyone else goes to login.

POST /register creates a new user entry with a random 16-byte salt, PBKDF2-hashed password, and default TOTP fields (secret is None until enrollment).

  • Below the register routes, add the login routes and the constant-time comparison helper:
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    return templates.TemplateResponse(request=request, name="login.html", context={})


@app.post("/login")
async def login_submit(response: Response, username: str = Form(), password: str = Form()):
    user = users.get(username)
    if not user:
        return RedirectResponse("/login?error=invalid", status_code=302)
    # Verify password using constant-time comparison
    expected = hash_password(password, user["salt"])
    if not hmac_compare_bytes(expected, user["password_hash"]):
        return RedirectResponse("/login?error=invalid", status_code=302)
    # Set session cookie and redirect to dashboard
    session_token = secrets.token_urlsafe(32)
    sessions[session_token] = username
    redirect = RedirectResponse("/dashboard", status_code=302)
    redirect.set_cookie("session", session_token, httponly=True)
    return redirect


def hmac_compare_bytes(a: bytes, b: bytes) -> bool:
    # Constant-time bytes comparison to prevent timing attacks
    if len(a) != len(b):
        return False
    result = 0
    for x, y in zip(a, b):
        result |= x ^ y
    return result == 0

What does this code do?

POST /login verifies the username and password, then sets a session cookie via set_cookie("session", ..., httponly=True). The httponly flag prevents JavaScript from reading the cookie.

The hmac_compare_bytes() function XORs every byte before deciding equality. This prevents timing attacks where an attacker measures response time to guess password hashes byte by byte.

  • Below the login routes, add the dashboard and logout routes:
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
    user = get_current_user(request)
    if not user:
        return RedirectResponse("/login", status_code=302)
    # Look up the username from the session
    username = sessions.get(request.cookies.get("session", ""), "")
    return templates.TemplateResponse(
        request=request,
        name="dashboard.html",
        context={"username": username, "totp_enabled": user["totp_enabled"]},
    )


@app.get("/logout")
async def logout(request: Request):
    session_token = request.cookies.get("session")
    if session_token:
        sessions.pop(session_token, None)
    redirect = RedirectResponse("/login", status_code=302)
    redirect.delete_cookie("session")
    return redirect

What does this code do?

The dashboard route reads the session, looks up the username, and passes both the username and 2FA status to the template. If no valid session exists, it redirects to login.

Logout removes the session from the server-side dict AND deletes the browser cookie. Both are needed so the session cannot be reused.

  • Save webapp/app.py.

The routes are ready, but they reference templates that do not exist yet. Time to create them.

  • Create the file webapp/templates/register.html with this content:
<!DOCTYPE html>
<html>
<head><title>Register - TOTP 2FA Demo</title></head>
<body>
    <h1>Register</h1>
    <form method="post" action="/register">
        <label>Username: <input type="text" name="username" required></label><br><br>
        <label>Password: <input type="password" name="password" required></label><br><br>
        <button type="submit">Register</button>
    </form>
    <p><a href="/login">Already have an account? Login</a></p>
</body>
</html>
  • Create webapp/templates/login.html:
<!DOCTYPE html>
<html>
<head><title>Login - TOTP 2FA Demo</title></head>
<body>
    <h1>Login</h1>
    <form method="post" action="/login">
        <label>Username: <input type="text" name="username" required></label><br><br>
        <label>Password: <input type="password" name="password" required></label><br><br>
        <button type="submit">Login</button>
    </form>
    <p><a href="/register">Need an account? Register</a></p>
</body>
</html>
  • Create webapp/templates/dashboard.html:
<!DOCTYPE html>
<html>
<head><title>Dashboard - TOTP 2FA Demo</title></head>
<body>
    <h1>Dashboard</h1>
    <p>Welcome, <strong>{{ username }}</strong>!</p>
    <p>2FA Status: {{ "Enabled" if totp_enabled else "Not enabled" }}</p>
    {% if not totp_enabled %}
    <p><a href="/enroll">Enable Two-Factor Authentication</a></p>
    {% endif %}
    <p><a href="/logout">Logout</a></p>
</body>
</html>

What do these templates do?

Each template is a minimal HTML form. register.html and login.html post to their respective endpoints.

dashboard.html uses Jinja2 template syntax ({{ }} and {% %}) to display the username, 2FA status, and a conditional enrollment link.

  • Save all three template files.
  • Confirm the templates exist by running:
ls webapp/templates/

You should see dashboard.html, login.html, and register.html listed.

  • Start the server by running:
uvicorn webapp.app:app --reload

What if the server was already running?

If you left uvicorn running from the previous step, it auto-reloads on file changes thanks to --reload. If you stopped it, run the command above to restart.

  • Open your browser and navigate to http://localhost:8000/register.
  • Enter a username (e.g. alice) and a password, then click Register.
  • You will be redirected to the login page. Enter the same credentials and click Login.

You should see the Dashboard page displaying your username and "2FA Status: Not enabled" with a link to enable 2FA.

Seeing a server error?

  • Check the terminal for a traceback. A TemplateNotFoundError means a template file is missing or in the wrong folder. Confirm your templates are inside webapp/templates/.
  • If you see ImportError, make sure your virtual environment is activated and all packages from requirements.txt are installed.

Help me debug my FastAPI registration flow.

Build the 2FA enrollment with QR code

Registration and login work. Now you will add the enrollment flow that connects your totpcore library to a QR code the learner scans with their phone.

  • In webapp/app.py, add the GET /enroll route above the dashboard function:
@app.get("/enroll", response_class=HTMLResponse)
async def enroll_page(request: Request):
    user = get_current_user(request)
    if not user:
        return RedirectResponse("/login", status_code=302)
    # Look up username from session
    username = sessions.get(request.cookies.get("session", ""), "")
    # Generate TOTP secret if user doesn't have one yet
    if not user["totp_secret"]:
        user["totp_secret"] = totpcore.generate_secret(20)
    # Build the otpauth:// URI for QR provisioning
    uri = totpcore.provisioning_uri(
        secret=user["totp_secret"], account=username, issuer="TOTP2FA-Demo"
    )
    # Generate QR code as base64-encoded PNG
    qr = qrcode.QRCode(version=1, box_size=6, border=4)
    qr.add_data(uri)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    buffer = io.BytesIO()
    img.save(buffer, format="PNG")
    qr_base64 = base64.b64encode(buffer.getvalue()).decode("ascii")
    return templates.TemplateResponse(
        request=request,
        name="enroll.html",
        context={"qr_base64": qr_base64, "secret": user["totp_secret"]},
    )

What does this code do?

This is where your totpcore library meets the web. It generates a random 20-byte secret (base32-encoded), builds an otpauth:// URI that authenticator apps understand, and renders it as a QR code.

The QR PNG is written to an in-memory buffer, base64-encoded, and embedded directly in the HTML as a data URI. No file system writes needed.

  • Below the enroll_page function, add the POST /enroll route that confirms enrollment:
@app.post("/enroll")
async def enroll_confirm(request: Request, code: str = Form()):
    user = get_current_user(request)
    if not user:
        return RedirectResponse("/login", status_code=302)
    # Decode the base32 secret and verify the submitted code
    secret_bytes = base64.b32decode(user["totp_secret"])
    try:
        matched_counter = totpcore.verify_totp(secret_bytes, code)
    except ValueError:
        return RedirectResponse("/enroll?error=invalid", status_code=302)
    # Mark 2FA as enabled, record counter for replay prevention
    user["totp_enabled"] = True
    user["last_used_counter"] = matched_counter
    return RedirectResponse("/dashboard", status_code=302)

What does this code do?

When the user submits their first 6-digit code from Google Authenticator, this route decodes the stored base32 secret back to raw bytes and passes it to your verify_totp() function.

If the code matches (within the 1-step time window), totp_enabled flips to True and the matched counter is stored. That counter becomes the baseline for replay prevention later.

  • Save webapp/app.py.
  • Create webapp/templates/enroll.html with the following content:
<!DOCTYPE html>
<html>
<head><title>Enroll 2FA - TOTP 2FA Demo</title></head>
<body>
    <h1>Set Up Two-Factor Authentication</h1>
    <p>Scan this QR code with Google Authenticator:</p>
    <img src="data:image/png;base64,{{ qr_base64 }}" alt="TOTP QR Code">
    <p>Or enter this secret manually: <code>{{ secret }}</code></p>
    <form method="post" action="/enroll">
        <label>Enter the 6-digit code from your app to confirm: <input type="text" name="code" maxlength="6" pattern="[0-9]{6}" required></label><br><br>
        <button type="submit">Confirm</button>
    </form>
</body>
</html>

What does this template do?

The <img> tag uses a data:image/png;base64, URI to embed the QR code inline. No separate image file is served.

The form collects a 6-digit code (pattern="[0-9]{6}" enforces digits only) and posts it to /enroll for verification. The manual secret display gives users a fallback if their camera cannot scan.

  • Save enroll.html.
  • In your browser, click the Enable Two-Factor Authentication link on your dashboard (or navigate directly to http://localhost:8000/enroll).

You should see a page titled "Set Up Two-Factor Authentication" with a black-and-white QR code and your base32 secret displayed below it.

Seeing a blank page or error instead of a QR code?

  • Check that qrcode is installed (pip list | grep qrcode should show 8.2).
  • Confirm you are logged in. If your session expired (e.g. server restarted), register again and log in. In-memory storage resets on restart.

Help me debug my QR code enrollment page.

Scan with Google Authenticator and confirm enrollment

The QR code on screen encodes the same otpauth:// URI format that every authenticator app expects. When you scan it, Google Authenticator starts generating 6-digit codes using the exact same algorithm you implemented in totpcore.

  • Open Google Authenticator on your phone.
  • Tap the + button, then select Scan a QR code.
  • Point your phone camera at the QR code displayed in your browser.

A new entry labelled "TOTP2FA-Demo" with your username should appear in Google Authenticator, showing a 6-digit code that refreshes every 30 seconds.

  • Before you submit the code, predict: what do you think happens when verify_totp() receives this code from your phone?
  • Type the current 6-digit code from Google Authenticator into the confirmation field on the enrollment page.
  • Click Confirm.

You should be redirected to the dashboard, and the 2FA status now reads "Enabled". Your phone's authenticator app is generating codes validated by cryptographic code you wrote from scratch.

Code timing matters

TOTP codes change every 30 seconds. If you see the code about to change (timer nearly empty), wait for the next one before submitting. Your verify_totp() accepts codes from the previous and next time step too (window of 1), but submitting during a transition is unnecessary risk.

Code rejected on confirm?

  • Make sure you are entering exactly 6 digits with no spaces.
  • Check that your phone's clock is accurate. TOTP depends on the client and server agreeing on the current time. Go to your phone's date/time settings and enable automatic time sync.
  • If the server restarted between scanning and confirming, the in-memory secret was lost. Delete the entry in Google Authenticator, refresh /enroll, and scan the new QR code.

Help me figure out why my TOTP code is being rejected during enrollment.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: webapp/app.py, register.html, login.html, enroll.html, and dashboard.html.

ⓧ I'd like to double check the full code

Your webapp/app.py should contain these sections in order. Scroll through your file and confirm each is present:

  • Imports (base64, hashlib, io, os, secrets, time, qrcode, FastAPI imports, totpcore).
  • App and template setup (app = FastAPI(), Jinja2Templates).
  • In-memory dicts (users, sessions).
  • Helper functions (hash_password, get_current_user).
  • Routes: home, register_page, register_submit, login_page, login_submit, enroll_page, enroll_confirm, dashboard, logout.
  • Utility function hmac_compare_bytes at the bottom.

Your templates folder should contain register.html, login.html, enroll.html, and dashboard.html.

Your crypto library now powers a real web application. Next up, you will add the two-phase login that requires both a password AND a TOTP code from your phone before granting access.

Add Login with TOTP Verification and Replay Prevention

Your 2FA enrollment is working. Users can register, scan a QR code, and confirm their setup with a valid code from Google Authenticator.

The missing piece is the login flow itself. Right now there is no way for an enrolled user to actually log back in and prove they still hold their authenticator device. This step builds the two-phase login (password, then TOTP code) and surfaces a critical security gap that RFC 6238 section 5.2 exists to address.

In this step, get ready to:
  • Build GET/POST routes for /login and /verify with supporting templates.
  • Discover the replay vulnerability by reusing a valid code within 30 seconds.
  • Fix it with last-accepted time step tracking per RFC 6238 section 5.2.
Implement the two-phase login flow

The login needs two phases. First, the user submits their username and password. If their account has 2FA enabled, the server issues a temporary pending token and redirects to a verification page that asks for the 6-digit TOTP code.

  • In webapp/app.py, find the register_submit function. Add the login routes directly below it (before the enroll_page function) by pasting this code:
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    return templates.TemplateResponse(request=request, name="login.html", context={})


@app.post("/login")
async def login_submit(response: Response, username: str = Form(), password: str = Form()):
    user = users.get(username)
    if not user:
        return RedirectResponse("/login?error=invalid", status_code=302)

    expected = hash_password(password, user["salt"])
    if not hmac_compare_bytes(expected, user["password_hash"]):
        return RedirectResponse("/login?error=invalid", status_code=302)

    if user["totp_enabled"]:
        # Store pending login in a temporary token
        token = secrets.token_urlsafe(32)
        sessions[f"pending:{token}"] = username
        return RedirectResponse(f"/verify?token={token}", status_code=302)

    # No 2FA enabled, log in directly
    session_token = secrets.token_urlsafe(32)
    sessions[session_token] = username
    redirect = RedirectResponse("/dashboard", status_code=302)
    redirect.set_cookie("session", session_token, httponly=True)
    return redirect

What does this code do?

  • GET /login renders the login form template.
  • POST /login looks up the user, verifies the password hash with constant-time comparison, then branches: if 2FA is enabled, it creates a pending token and redirects to /verify. Otherwise it logs the user in directly.
  • The pending token is stored in the sessions dict with a pending: prefix so it does not collide with real session tokens.
  • Save webapp/app.py.

Now add the verification routes. These handle the second phase: prompting for and validating the 6-digit TOTP code.

  • Directly below the login_submit function you just added, paste the verify routes:
@app.get("/verify", response_class=HTMLResponse)
async def verify_page(request: Request, token: str):
    return templates.TemplateResponse(
        request=request, name="verify.html", context={"token": token}
    )


@app.post("/verify")
async def verify_submit(token: str = Form(), code: str = Form()):
    pending_key = f"pending:{token}"
    username = sessions.pop(pending_key, None)
    if not username:
        return RedirectResponse("/login?error=expired", status_code=302)

    user = users[username]
    secret_bytes = base64.b32decode(user["totp_secret"])

    try:
        matched_counter = totpcore.verify_totp(secret_bytes, code)
    except ValueError:
        # Re-store pending token so user can retry
        sessions[pending_key] = username
        return RedirectResponse(f"/verify?token={token}&error=invalid", status_code=302)

    # Log the user in
    session_token = secrets.token_urlsafe(32)
    sessions[session_token] = username
    redirect = RedirectResponse("/dashboard", status_code=302)
    redirect.set_cookie("session", session_token, httponly=True)
    return redirect

What does this code do?

  • GET /verify renders the code-entry form, passing the pending token as a hidden field.
  • POST /verify pops the pending token from the session store to find the username, decodes the user's base32 secret, and calls your verify_totp() function.
  • If the code is invalid, the pending token is re-stored so the user can retry without re-entering their password.
  • On success, a real session cookie is set and the user reaches the dashboard.
  • Save webapp/app.py.

Next, create the two HTML templates these routes need.

  • Create webapp/templates/login.html with the following content:
<!DOCTYPE html>
<html>
<head><title>Login - TOTP 2FA Demo</title></head>
<body>
    <h1>Login</h1>
    <form method="post" action="/login">
        <label>Username: <input type="text" name="username" required></label><br><br>
        <label>Password: <input type="password" name="password" required></label><br><br>
        <button type="submit">Login</button>
    </form>
    <p><a href="/register">Need an account? Register</a></p>
</body>
</html>
  • Save login.html.
  • Create webapp/templates/verify.html with the following content:
<!DOCTYPE html>
<html>
<head><title>Verify 2FA - TOTP 2FA Demo</title></head>
<body>
    <h1>Two-Factor Verification</h1>
    <p>Enter the 6-digit code from Google Authenticator:</p>
    <form method="post" action="/verify">
        <input type="hidden" name="token" value="{{ token }}">
        <label>Code: <input type="text" name="code" maxlength="6" pattern="[0-9]{6}" required autofocus></label><br><br>
        <button type="submit">Verify</button>
    </form>
</body>
</html>
  • Save verify.html.
  • Check the terminal where uvicorn is running. It should show a reload message confirming it picked up your changes.
  • Open http://localhost:8000/login in your browser. You should see the Login heading with username and password fields.

Seeing a server error?

  • Check that the login routes are placed BETWEEN register_submit and enroll_page in webapp/app.py. Route order matters for readability but misplaced decorators can cause syntax errors.
  • Confirm login.html is inside webapp/templates/ (not the project root). A missing template triggers a 500 error.
  • Look for a TemplateNotFound traceback in your terminal for the exact filename it cannot find.

Why is my login page not loading?

Test the login flow end-to-end

The routes are in place. Time to test the full two-phase login with your phone.

  • If you are still logged in from the previous step, click **Logout** on the dashboard.
  • Navigate to http://localhost:8000/login.
  • Enter the username and password you registered earlier, then click **Login**.
  • You should be redirected to the Two-Factor Verification page.
  • Open Google Authenticator on your phone. Find the TOTP2FA-Demo entry and note the current 6-digit code.
  • Enter the code and click **Verify**.

You should land on the Dashboard page with your username displayed. The two-phase login works.

Now test whether the same code can be reused. Before you try, ask yourself: if you log out and log back in within 30 seconds using the exact same 6-digit code, do you think the server will accept it or reject it?

  • Click **Logout** on the dashboard.
  • Log in again with the same username and password.
  • On the verification page, enter the SAME 6-digit code you used moments ago (it is still valid within the 30-second window).
  • Click **Verify**.

You land on the dashboard again. The same code worked twice. This is a replay vulnerability.

Why is code reuse a problem?

An attacker who intercepts a valid 6-digit code (via shoulder-surfing, a compromised network, or a phishing page) can reuse it within the same 30-second window to log in as the victim. RFC 6238 section 5.2 explicitly addresses this: the server must track the last time step used for each user and reject any code at or before it.

Add replay prevention

The fix stores the matched time-step counter after each successful verification and rejects any code whose counter is not strictly greater than the last accepted one.

  • In webapp/app.py, find the verify_submit function. Locate the comment # Log the user in near the bottom of the function.
  • Find this line:
    # Log the user in
    session_token = secrets.token_urlsafe(32)
  • Replace that comment and everything below it in the function with the following code that adds replay prevention before issuing the session:
    # Replay prevention: reject codes at or before last used counter
    if matched_counter <= user["last_used_counter"]:
        sessions[pending_key] = username
        return RedirectResponse(f"/verify?token={token}&error=replay", status_code=302)

    user["last_used_counter"] = matched_counter

    session_token = secrets.token_urlsafe(32)
    sessions[session_token] = username
    redirect = RedirectResponse("/dashboard", status_code=302)
    redirect.set_cookie("session", session_token, httponly=True)
    return redirect

What does this code do?

  • After verify_totp() returns the matched counter (the time step that produced a valid code), the new check compares it to user["last_used_counter"]. If the counter is not strictly greater, the code was already used.
  • On success, last_used_counter is updated so the same time step can never be accepted again for this user.
  • The redirect appends error=replay to the URL so the verify page can display a "Code already used" message.
  • Save webapp/app.py.

Before you test, predict: if you log in with a fresh code and then try the same code again, what HTTP redirect will the server return on the second attempt?

  • Open http://localhost:8000/login and log in with your username and password.
  • On the verification page, enter the current 6-digit code from Google Authenticator and click **Verify**.
  • You should reach the dashboard.
  • Click **Logout**.
  • Log in again with username and password.
  • Enter the SAME 6-digit code on the verification page.

This time the server redirects you back to the verify page with error=replay in the URL. The same code no longer works twice. Replay prevention is enforced.

Same code still working on second use?

  • Make sure you replaced the entire bottom section of verify_submit starting from the old comment. If both the old and new session-creation code exist, the function may hit the old code path first.
  • Confirm that user["last_used_counter"] = matched_counter appears AFTER the replay check and BEFORE the session creation. If it is missing, the counter never updates.
  • Restart the server if uvicorn did not auto-reload. The in-memory user store resets on restart, so you will need to register and enroll again.

Why is my replay prevention not working?

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: webapp/app.py, login.html, and verify.html.

ⓧ I'd like to double check the full code

Since webapp/app.py is a long file, scroll up through your code and confirm the following sections are present in order:

  • Imports, app and templates initialization, users and sessions dicts.
  • hash_password and get_current_user helper functions.
  • Routes: / (home), /register (GET + POST), /login (GET + POST), /verify (GET + POST), /enroll (GET + POST), /dashboard (GET), /logout (GET).
  • The replay prevention check inside verify_submit: if matched_counter <= user["last_used_counter"] followed by user["last_used_counter"] = matched_counter on success.
  • hmac_compare_bytes helper at the bottom.

For the templates, confirm login.html posts to /login with fields named username and password, and verify.html posts to /verify with hidden field token and input field code.

Your 2FA system now enforces a complete security loop. A user must know the password AND hold the authenticator device, and each code is single-use. Next up, you will push this further with rate limiting to prevent brute-force attacks on the 6-digit code space.

Secret mission

Add Rate Limiting and Account Lockout

Your 6-digit TOTP code has 1,000,000 possible values. Without rate limiting, an attacker could brute-force the code in seconds by submitting guesses as fast as the server responds. Can you add account lockout to make the math infeasible?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The totp-2fa/ project folder on your Desktop (includes the virtual environment, source code, tests, and templates).
  • The uvicorn dev server process (if still running in your terminal).
  • The TOTP entry in Google Authenticator on your phone.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your project folder stays on your Desktop at ~/Desktop/totp-2fa/.
  • If you stopped the uvicorn server, restart it anytime by activating the virtual environment and running uvicorn webapp.app:app --reload.
  • The Google Authenticator entry stays on your phone and keeps generating codes for your demo app.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Your files stay on disk so you can pick up where you left off.

  • If the uvicorn server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate the virtual environment by running:
deactivate
  • Your totp-2fa/ folder and all code remain on your Desktop. To resume later, cd ~/Desktop/totp-2fa, activate the venv, and restart uvicorn.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your code, the virtual environment, and all installed packages.

  • If the uvicorn server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the entire project folder by running:
cd ~/Desktop
rm -rf totp-2fa

On Windows, run this instead:

cd ~/Desktop
rmdir /s /q totp-2fa
  • Open Google Authenticator on your phone, long-press the TOTP2FA-Demo entry, and delete it.
  • Confirm the folder is gone by running ls ~/Desktop and verifying totp-2fa no longer appears.

Nice Work!

Nice Work!

You just built a complete two-factor authentication system from the cryptographic core up to a working web demo. Your phone's 6-digit codes are validated by HMAC-SHA1 logic you wrote from scratch.

You've learned how to:

  • Implement HOTP and TOTP from raw HMAC-SHA1 and dynamic truncation, proven correct against all 10 RFC 4226 and 6 RFC 6238 official test vectors.
  • Build a FastAPI web app with user registration, QR code enrollment scannable by Google Authenticator, and a two-phase login flow protected by password plus TOTP.
  • Enforce replay prevention per RFC 6238 section 5.2, tracking the last-accepted time step so the same code can never be reused within its 30-second window.
  • Secret Mission: Added rate limiting with account lockout after 5 failed attempts, defending the 6-digit code space against brute-force attacks (OWASP A07:2021).

Ready to quiz yourself?