Time-based one-time passwords (TOTP) power authenticator apps behind "enter the 6-digit code" prompts. The mechanism is simple on paper — a shared secret, the current time, and HMAC — but small mistakes in Base32 decoding, time-step windows, or secret handling break enrollments and lock out users. This guide explains RFC 6238 in practical terms for engineers implementing or debugging 2FA, and clarifies when browser tools are safe to use.
Bottom line: TOTP = HMAC(secret, floor(unix_time / 30)) truncated to digits. Secrets are usually Base32-encoded. Allow ±1 step of clock skew. Never paste production MFA secrets into online generators; use client-side tools only with disposable test secrets.
Who this guide is for
Backend engineers adding MFA, frontend engineers wiring enrollment QR flows, and SREs debugging "codes never work" tickets. Familiarity with HMAC and Unix time helps; cryptography research background is not required.
RFC 6238 in plain language
RFC 6238 defines TOTP as a moving-factor OTP where the moving factor is time. It builds on HOTP (RFC 4226), which uses a counter instead of time.
T = floor(current_unix_time / X) // X usually 30 seconds
TOTP = Truncate(HMAC-SHA1(secret, T)) → d digits (usually 6)
| Parameter |
Common default |
Notes |
Time step X |
30 seconds |
Some systems use 60 |
| Digits |
6 |
7–8 exist; UX prefers 6 |
| Algorithm |
SHA-1 (historical default) |
SHA-256 / SHA-512 also allowed |
| Secret |
160+ bits recommended |
Delivered as Base32 in otpauth:// URIs |
Authenticator apps (Google Authenticator, 1Password, Authy, etc.) compute the same function. If server and app disagree on secret, algorithm, digits, or time, codes will not match.
Enrollment: otpauth:// and QR codes
Typical provisioning URI:
otpauth://totp/Example:ada@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
| Field |
Purpose |
secret |
Base32-encoded shared key |
issuer |
Shown in the authenticator UI |
algorithm |
SHA1, SHA256, or SHA512 |
digits |
Code length |
period |
Time step in seconds |
The QR code is just that URI. Scanning writes the secret into the authenticator. After enrollment, destroy or encrypt server-side recovery paths; anyone with the secret can mint valid codes.
Base32 secrets
TOTP secrets are binary keys. They are almost always transported as Base32 (RFC 4648) so they are easy to type and embed in URIs.
Properties:
- Alphabet:
A–Z and 2–7 (not the full Base64 set)
- Case-insensitive in practice (apps often uppercase)
- Padding with
= may appear; many authenticators accept unpadded secrets
If a user pastes a secret and codes fail, verify Base32 decoding before blaming clocks. Use the Base32 encoder/decoder to confirm the string decodes cleanly, then feed the same secret into a TOTP generator for a known test vector.
// Pseudocode — server stores raw bytes, not the Base32 string alone
const secretBytes = base32Decode(enrollmentSecret);
const code = totp(secretBytes, { period: 30, digits: 6, algorithm: "SHA-1" });
The 30-second window and verification
Servers should not accept only the current time step. Users submit codes near boundaries; clocks drift.
Recommended acceptance window:
for step in { T-1, T, T+1 }:
if constantTimeEqual(expected(step), userCode):
accept
| Skew policy |
UX |
Risk |
| Current step only |
Brittle near :00/:30 |
Low replay window |
| ±1 step (±30s) |
Industry common |
Acceptable |
| ±2 or more |
Forgiving |
Larger replay window |
After a successful check, reject code replay for that step (store last used counter/step per user). Otherwise an intercepted code remains valid until the window ends.
HMAC under the hood
TOTP's core is HMAC over an 8-byte big-endian counter:
HMAC-SHA1(key, counter_bytes) → 20 bytes → dynamic truncation → modulo 10^digits
When debugging interoperability, compare intermediate HMAC outputs with a known library. The HMAC generator helps verify key + message → digest for fixture tests. For end-to-end codes, prefer a dedicated TOTP generator that applies truncation correctly — raw HMAC hex is not the 6-digit code.
Clock skew: the #1 production failure
Symptoms:
- Codes work on one laptop but not another
- Failures cluster around step boundaries
- Docker containers without NTP drift minutes away from reality
Checks:
- Compare server UTC (
date -u) to time.gov or NTP peers.
- Confirm containers receive host time or run chrony/systemd-timesyncd.
- Decode the user's complaint time — "it fails at 29 seconds" often means missing ±1 window.
- Ensure both sides use Unix time in UTC, not local civil time.
# On the API host
date +%s
timedatectl status # systemd systems
Mobile authenticators use the phone clock. If the phone's automatic time is off, only that user fails — educate via support playbooks, do not widen server windows indefinitely.
Implementation checklist
Server
- Generate secrets with a CSPRNG (20 bytes+).
- Store secrets encrypted at rest (KMS/envelope), never in plain logs.
- Verify with constant-time compare and ±1 step.
- Rate-limit verification attempts per account and IP.
- Offer one-time recovery codes generated separately from TOTP.
- Support algorithm/period/digits parameters explicitly — do not assume Google Authenticator defaults if you document otherwise.
Client / enrollment UX
- Show QR + manual Base32 entry for accessibility.
- Require a successful code challenge before enabling MFA.
- Warn users to save recovery codes before closing the modal.
- Do not log provisioning URIs.
Online TOTP tools are excellent for learning and fixtures. They are dangerous for production MFA secrets.
Safe:
- Generating a disposable secret for local integration tests
- Verifying your library against a known test secret from RFC fixtures
- Teaching how windows roll every 30 seconds
Unsafe:
- Pasting a production user's authenticator secret into any website
- Using a public tool as your production second factor
- Screenshotting live secrets into tickets
codefunc's TOTP generator, Base32, and HMAC generator run entirely in your browser — no upload of the secret to a server. Even so, treat production secrets as credentials: keep them in a password manager or HSM-backed vault, not in a browser tab on a shared machine.
Rule of thumb: If the secret protects a real account, it should never appear in a support channel, gist, or casual online form.
Test vectors and local verification
RFC 6238 includes test values (SHA-1, 6 digits, 30-second step). When your codes disagree with an authenticator:
- Fix time and algorithm first.
- Confirm Base32 decode length matches expectations.
- Compare codes from your library, the authenticator app, and a client-side TOTP generator using the same test secret.
- If the tool and authenticator match but your server differs, your truncation or byte-order is wrong — not the user.
TOTP vs alternatives
| Method |
Phishing resistance |
Offline |
Notes |
| TOTP |
Weak (codes can be phished in real time) |
Yes |
Ubiquitous |
| SMS OTP |
Weak |
Needs signal |
SIM swap risk |
| WebAuthn / passkeys |
Strong |
Device-bound |
Prefer for new systems |
| Hardware OTP (OATH) |
Medium |
Yes |
Same TOTP math, better secret storage |
TOTP remains a solid second factor when passkeys are not yet universal. Combine with phishing-resistant options where possible.
Practical takeaway
Implement TOTP as RFC 6238 specifies: Base32-transported secrets, 30-second steps, HMAC truncation, ±1 verification window, and replay protection. Debug with synchronized clocks and known test secrets. Use the TOTP generator with Base32 and HMAC helpers for fixtures and interoperability checks — and never feed production MFA secrets into any tool you do not fully trust, even client-side ones on shared devices.
Sources