Skip to main content
Pylon ships RFC 6238 TOTP in the binary. It works with Google Authenticator, 1Password, Authy, Apple’s Passwords, Bitwarden, and every other authenticator app. Pylon also ships single-use backup codes (SHA-256 hashed at rest), per-account rate limiting on verify, and at-rest encryption of the TOTP seed.

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

Response:
The client renders 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:
A wrong code returns 401 INVALID_TOTP_CODE. This matches /password/change, which also requires the current password.

Verifying

Response:
  • enrolled: true is set only on the first successful verify (when totpVerified flips from false to true).
  • trust_device: true is set when the request body included trust_device: true and Pylon minted a pylon_trusted_device cookie. See Trusted devices.
Failure modes:

Backup codes

If totpBackupCodes 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:
The current TOTP code is required. The response includes the plaintext codes. Pylon does not persist the plaintext codes server-side; it shows them once.
Each backup code is single-use. Pylon uses a compare-and-swap check: it consumes the matching index, re-reads the row, and fails with 409 TOTP_RACE if a parallel verify already consumed that code. This stops two concurrent verifies from consuming the same backup code.

Disabling

Disabling requires the current code. Pylon wipes 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 set PYLON_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:
Stored shape: 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:
Generate a key:
To rotate the key, set the new key and leave the old one in place. 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 shared AuthRateLimiter, 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: true on /totp/verify to skip the prompt for 30 days.
  • Password: TOTP usually pairs with email and password sign-in.