Algorithm
- HOTP (RFC 4226) uses SHA-1 HMAC with 6 digits. Every authenticator app implements this mode.
- TOTP (RFC 6238) uses a 30-second step with a ±1 step tolerance window on verify. Codes near the rollover boundary still validate.
- 160-bit secret, base32-encoded for the provisioning URL.
Endpoints
All endpoints are under/api/auth/. Enrollment and management require a real session. Pylon refuses API-key auth with 403 API_KEY_AUTH_FORBIDDEN.
The user entity in your schema needs three optional fields:
Enrolling
url as a QR code (or shows secret for manual entry). The user scans, opens their authenticator app, then calls /totp/verify with the current 6-digit code to finalize enrollment.
Pylon persists the secret immediately, in totpVerified=false state. The secret stays “pending” until /totp/verify succeeds for the first time. Calling /totp/enroll again while pending rotates the secret freely.
Re-enrollment when already verified requires the current TOTP code in the body. This stops an attacker with only the session cookie from silently rotating the secret to one they control:
401 INVALID_TOTP_CODE. This matches /password/change, which also requires the current password.
Verifying
enrolled: trueis set only on the first successful verify (whentotpVerifiedflips fromfalsetotrue).trust_device: trueis set when the request body includedtrust_device: trueand Pylon minted apylon_trusted_devicecookie. See Trusted devices.
Backup codes
IftotpBackupCodes is populated on the user row, /totp/verify accepts a backup code as an alternative to the live TOTP code. Pylon hashes backup codes with SHA-256 at rest (hex-encoded) and shows the plaintext to the user once, at generation time.
Generating a fresh set invalidates the previous one:
409 TOTP_RACE if a parallel verify already consumed that code. This stops two concurrent verifies from consuming the same backup code.
Disabling
totpSecret, totpVerified, and totpBackupCodes from the user row.
At-rest encryption
The TOTP seed is sensitive. Anyone with the bytes can generate codes forever. When you setPYLON_TOTP_ENCRYPTION_KEY, Pylon seals the seed before persisting it, using an HMAC-SHA256 stream cipher in counter mode, keyed off the env var, with a 16-byte CSPRNG nonce:
enc:<nonce-hex>:<ciphertext-hex>. This construction is not AEAD, so there is no integrity tag. A flipped bit just produces a TOTP code that fails to verify, and the user re-enrolls. This trade-off avoids adding an AEAD dependency.
Without the key, Pylon stores the plaintext base32 value and logs a warning at boot:
unseal_secret accepts both enc:... blobs (decrypted with the current key) and plaintext (the legacy format). Walk the user table during a deploy and re-seal each seed with the new key.
Rate limiting
Pylon rate-limits verify per account through the sharedAuthRateLimiter, the same limiter that gates password login. When a caller hits the limit, the response is 429 RATE_LIMITED with retry_after_secs. This defends against an attacker who guesses the live code (about 1 in a million per try) or tries to churn through backup codes.
Configuration
Where to go next
- Passkeys: a phishing-resistant FIDO2 method, as an alternative to TOTP or alongside it.
- Trusted devices: set
trust_device: trueon/totp/verifyto skip the prompt for 30 days. - Password: TOTP usually pairs with email and password sign-in.