Skip to content

TOTP — Time-based 2FA

Craft Easy ships with an RFC 6238-compliant TOTP implementation (craft_easy.core.auth.totp.TOTPService). It works with Google Authenticator, Authy, 1Password, Bitwarden and every other authenticator app that speaks the otpauth:// URI scheme.

TOTP is always a second factor on top of OTP or OAuth2 login — never the only factor. When a user has TOTP enabled, the first-factor login returns a pre-auth token with requires_2fa=true and the caller must then call /auth/2fa/verify with a TOTP code.

Setup flow

1. User logs in with OTP/OAuth2         → JWT user token
2. POST /auth/2fa/setup                 → secret + otpauth:// URI
3. User scans QR, enters first code
4. POST /auth/2fa/enable                → 2FA is now required on all logins

Generating the secret and QR URI

resp = client.post("/auth/2fa/setup")
body = resp.json()

print(body["secret"])         # "JBSWY3DPEHPK3PXP..." (base32)
print(body["uri"])            # otpauth://totp/My%20App:user@example.com?secret=...&issuer=My%20App&algorithm=SHA1&digits=6&period=30
print(body["backup_codes"])   # list of one-time recovery codes

Render the URI as a QR code in the client (any QR library will do — the URI is standard). The user scans it with their authenticator app.

Under the hood, TOTPService.generate_secret() returns a base32-encoded random string, and get_provisioning_uri() assembles the standard URI:

from craft_easy.core.auth import TOTPService

totp = TOTPService(digits=6, period=30, window=1)
secret = totp.generate_secret()  # "JBSWY3DPEHPK3PXP..."
uri = totp.get_provisioning_uri(secret, "user@example.com", issuer="My App")

Enabling 2FA

The user types the code shown in their app to confirm enrolment:

resp = client.post("/auth/2fa/enable", json={"code": "123456"})
resp.raise_for_status()
# 2FA is now active on this account

The server verifies the code with TOTPService.verify(secret, code) and only flips the two_factor_enabled flag if the code matches.

Login with 2FA enabled

# Step 1 — regular OTP login
r = httpx.post(f"{BASE}/auth/login/verify", json={
    "target": "user@example.com",
    "code": "123456",
    "application": "my-app",
})
token = r.json()["token"]

if r.json()["requires_2fa"]:
    # Step 2 — verify the TOTP code
    r2 = httpx.post(
        f"{BASE}/auth/2fa/verify",
        json={"code": "874321"},
        headers={"X-API-Key": token},
    )
    token = r2.json()["token"]

Only the final token (from /auth/2fa/verify) is usable on other endpoints. A pre-auth token rejected by any resource with 403 Complete two-factor authentication first.

Backup codes

At enrolment the user is given a list of single-use backup codes — TOTP_BACKUP_CODE_COUNT of them, defaulting to 10. Each can be used exactly once in place of a TOTP code:

httpx.post(f"{BASE}/auth/2fa/verify",
    json={"code": "A1B2-C3D4"},
    headers={"X-API-Key": token},
)

Show the codes once, tell the user to store them somewhere safe, and offer a regeneration endpoint:

resp = client.post("/auth/2fa/regenerate-backup-codes")
new_codes = resp.json()["backup_codes"]

Verifying codes programmatically

from craft_easy.core.auth import TOTPService

totp = TOTPService()
assert totp.verify("JBSWY3DPEHPK3PXP...", "874321") in (True, False)

verify() accepts codes within ±window periods of the current time to absorb clock drift between the client and server. With the default window=1 and period=30, a code is valid from 30s in the past to 30s in the future — 90s of wall-clock tolerance.

Configuration

Setting Default Purpose
TOTP_ISSUER "My App" Name shown in the authenticator app. Always set this to your brand.
TOTP_DIGITS 6 Code length. Google Authenticator expects 6.
TOTP_INTERVAL 30 Time step in seconds. Do not change unless you know what you are doing.
TOTP_VALID_WINDOW 1 How many extra periods (past and future) to accept.
TOTP_BACKUP_CODE_COUNT 10 Backup codes generated at setup.

Disabling 2FA

client.delete("/auth/2fa")

The user must re-authenticate with their current TOTP code as part of the request. Disabling does not delete the user; it clears two_factor_enabled, totp_secret, and any backup codes.

Endpoints reference

Method Path Purpose
POST /auth/2fa/setup Generate a new secret, QR URI and backup codes.
POST /auth/2fa/enable Confirm enrolment by submitting the first code.
POST /auth/2fa/verify Submit a TOTP or backup code after a pre-auth login.
DELETE /auth/2fa Disable 2FA for the current user.
GET /auth/2fa/status Whether 2FA is active and how many backup codes remain.
POST /auth/2fa/regenerate-backup-codes Invalidate existing backup codes and issue a new set.