How TOTP 2FA Codes Work: RFC 6238 Math, Base32 Secrets, and Clock Drift
TOTP 2FA Authenticator Simulator
Paste a Base32 secret and watch live 6-digit codes rotate on a 30-second timer. Secrets are decoded in browser memory and never transmitted.
What TOTP Actually Is
Time-based One-Time Password (TOTP) is defined in RFC 6238. It is not a new algorithm — it is a thin wrapper around HMAC-based One-Time Password (HOTP), specified in RFC 4226. HOTP derives a code from a shared secret and a moving counter. TOTP simply says: use the clock as the counter.
That one substitution is what makes authenticator apps work offline. There is no request to Google, Authy, or your identity provider when a code appears. The phone holds the secret, reads its own clock, and computes the answer. The server does the identical computation and compares.
What you get from this guide: the exact formula, the truncation step almost every explanation skips, the enrolment URI format, and the three failure modes that generate support tickets.
The RFC 6238 Formula, Step by Step
Step 1: Derive the time counter
T = floor((Current Unix Time - T0) / X)
| Symbol | Meaning | Default |
|---|---|---|
Current Unix Time | Seconds since 1970-01-01T00:00:00Z, UTC | — |
T0 | Epoch offset from which counting starts | 0 |
X | Time step in seconds | 30 |
At Unix time 1789000000, with defaults, T = floor(1789000000 / 30) = 59633333.
Step 2: Compute the HMAC
T is encoded as an 8-byte big-endian unsigned integer and passed as the message to HMAC, keyed with the raw secret:
HS = HMAC-SHA1(K, T) // 20-byte digest
The default hash is SHA-1. RFC 6238 explicitly permits HMAC-SHA256 and HMAC-SHA512. Using SHA-1 here is not the weakness people assume — HMAC’s security relies on the pseudorandomness of the compression function, not on collision resistance, so the known SHA-1 collision attacks do not translate into TOTP forgery. Read the underlying construction in the HMAC signature guide.
Step 3: Dynamic truncation
This is the step most tutorials omit, and the reason a naive implementation produces codes the server rejects.
offset = HS[19] & 0x0F // low 4 bits of the last byte
P = HS[offset..offset+3] // 4 consecutive bytes
binCode = P & 0x7FFFFFFF // clear the most significant bit
Masking the high bit removes any signed-integer ambiguity across language implementations. The result is a 31-bit integer.
Step 4: Reduce to digits
Code = binCode mod 10^Digits
With Digits = 6, binCode = 1284755224 yields 755224 — the canonical RFC 4226 test vector. Codes are zero-padded to the full width: 000042 is a valid code, 42 is not.
Reference implementation
// Browser-native TOTP with Web Crypto. rawKey = Base32-decoded secret bytes.
async function totp(rawKey, { digits = 6, step = 30, algo = "SHA-1" } = {}) {
const counter = Math.floor(Date.now() / 1000 / step);
// 8-byte big-endian counter
const msg = new DataView(new ArrayBuffer(8));
msg.setUint32(0, Math.floor(counter / 2 ** 32));
msg.setUint32(4, counter >>> 0);
const key = await crypto.subtle.importKey(
"raw", rawKey, { name: "HMAC", hash: algo }, false, ["sign"]
);
const hs = new Uint8Array(await crypto.subtle.sign("HMAC", key, msg.buffer));
const offset = hs[hs.length - 1] & 0x0f;
const bin =
((hs[offset] & 0x7f) << 24) |
(hs[offset + 1] << 16) |
(hs[offset + 2] << 8) |
hs[offset + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
Base32 Secrets: Why Not Base64?
The shared secret is transported as Base32 (RFC 4648, the same document that defines Base64 — see the Base64 encoding guide for the shared 5-bit and 6-bit chunking mechanics).
Base32 is the deliberate choice for three reasons:
- Case-insensitive alphabet (
A-Zand2-7) — a user can retype it from a printed backup without shift-key errors. - No visually ambiguous characters —
0,1, and8are excluded, soO/0andl/1confusion disappears. - Safe in URIs and QR codes without percent-encoding.
Practical detail that breaks implementations: Base32 padding (=) is optional in authenticator secrets, and most apps strip whitespace and lowercase before decoding. A 160-bit secret encodes to 32 Base32 characters. RFC 4226 requires a minimum of 128 bits and recommends 160 bits.
The Enrolment QR Code: otpauth:// URI
The QR code you scan is a plain text URI in the Key Uri Format:
otpauth://totp/Acme%20Inc:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme%20Inc&algorithm=SHA1&digits=6&period=30
| Parameter | Purpose | Practical note |
|---|---|---|
secret | Base32 shared key | Required. Everything else has a default. |
issuer | Service name shown in the app | Set it in both the label prefix and the query parameter — older apps read only one. |
algorithm | SHA1, SHA256, SHA512 | Widely ignored by popular apps, which assume SHA-1. |
digits | 6 or 8 | Also frequently ignored. |
period | Time step in seconds | Also frequently ignored. |
Design consequence: if your server issues algorithm=SHA256&digits=8 and your users authenticate with an app that silently assumes SHA-1 and 6 digits, every code fails and the failure is indistinguishable from a wrong password. Unless you control the client, stay on the SHA-1 / 6-digit / 30-second defaults.
Clock Drift, Validation Windows, and Replay
TOTP has no synchronisation channel. The protocol’s entire tolerance mechanism is the server checking neighbouring time steps.
| Server window | Effective validity | Trade-off |
|---|---|---|
T only | 0-30 s | Rejects codes typed slowly; poor UX |
T-1, T, T+1 | Up to 90 s | Common default; absorbs drift and typing delay |
T-2 … T+2 | Up to 150 s | Use only with strict rate limiting |
Three rules that separate a correct deployment from a fragile one:
- Run NTP on the server. Client drift is the user’s problem; server drift breaks every user at once.
- Burn the code after use. RFC 6238 § 5.2 is explicit: accept each code once per user per time step. Without this, a code intercepted at second 1 stays replayable for the rest of the window.
- Rate-limit attempts. A 6-digit code is 1,000,000 possibilities. At an unlimited request rate, an attacker inside a 90-second window has real odds. Cap at roughly 5 failures, then lock or back off exponentially.
Where TOTP Fits in 2026
| Factor | SMS OTP | TOTP (RFC 6238) | FIDO2 / Passkey |
|---|---|---|---|
| Works offline | ❌ | ✅ | ✅ |
| Resists SIM swap | ❌ | ✅ | ✅ |
| Resists real-time phishing | ❌ | ❌ | ✅ (origin-bound) |
| Server holds a reusable secret | ✅ (risk) | ✅ (risk) | ❌ (public key only) |
| Recovery burden | Low | Medium (backup codes) | Medium (multi-device sync) |
TOTP’s structural weakness is the shared secret at rest. The server must store the key in a form it can compute with, so a database breach exposes every enrolled seed unless the seeds are encrypted with a key held outside the database. Store them encrypted, and treat the enrolment QR code as a credential — a screenshot in a chat thread is a bypassed second factor.
Step-by-Step: Testing TOTP Codes with Toolbox
- Open the tool: visit the Toolbox TOTP Authenticator Simulator.
- Paste the Base32 secret produced by your enrolment endpoint (spacing and case are normalised for you).
- Set algorithm, digits, and period to match your server configuration — this is where SHA-256 or 8-digit mismatches surface immediately.
- Compare against your backend: call your verification endpoint with the displayed code while the countdown ring is above roughly 5 seconds, so a step boundary does not confuse the result.
- Reproduce drift deliberately: verify a code just as the ring empties to confirm your server actually accepts
T-1and does not silently reject late submissions.
Outcome: you can prove whether a failing 2FA flow is a secret-encoding bug, an algorithm mismatch, or a clock/window problem — without installing a mobile app or exposing the seed to a third-party service.
Related guides: HMAC signature verification · SHA-256 and MD5 hashing · JWT claims inspection
Frequently Asked Questions
How does an authenticator app generate a 6-digit code without internet access? ▼
The app stores a shared secret exchanged once during enrolment (usually via a QR code). Every 30 seconds it computes HMAC-SHA1 over the current time step (Unix time divided by 30), truncates the 20-byte digest to a 31-bit integer, and takes that value modulo 1,000,000 for 6 digits. Both phone and server hold the same secret and read the same clock, so they derive the same code with no network round trip.
Why do TOTP codes change every 30 seconds? ▼
RFC 6238 defines a time step X (default 30 seconds) and computes T = floor((current Unix time - T0) / X), with T0 normally 0. Each time T increments, the HMAC input changes and a new code appears. Thirty seconds balances usability against how long a stolen code stays valid.
What happens if my phone's clock is wrong? ▼
TOTP has no synchronisation protocol, so a device off by more than one time step produces codes the server rejects. Most servers accept the previous and next step (roughly a 90-second window) to absorb drift and typing delay. Persistent failures are fixed by enabling automatic network time on the device, not by retyping codes.
Is TOTP still secure in 2026, or should I move to passkeys? ▼
TOTP is far stronger than SMS one-time passwords and satisfies most compliance baselines, but it is phishable: a real-time phishing proxy can relay the digits inside the validity window, and the shared seed can leak from a breached database or a screenshotted QR code. Where phishing is in scope, FIDO2 or WebAuthn passkeys are stronger because the signed challenge is bound to the origin.