title: “Implementing 2FA in Go: TOTP from RFC 6238, the 3-phase enrollment state machine, and recovery codes — stdlib only” image: /images/covers/implementing-2fa.svg date: 2026-10-17T09:00:00+00:00 author: “Punit Naran” description: “Two-factor authentication is a function of time and a shared secret, and the whole standard (RFC 6238) fits in ~80 lines of Go stdlib: crypto/hmac, crypto/rand, encoding/base32. This post implements TOTP from the RFC, the 3-phase setup/activate/commit state machine that prevents the classic ‘I scanned the QR but my phone broke’ lockout, a ±1 clock-drift window, and one-shot recovery codes stored as hashes — all with no third-party TOTP SDK.” tags: [“Go”, “Authentication”, “2FA”, “TOTP”, “RFC 6238”, “Recovery Codes”, “State Machine”] draft: false
Implementing 2FA in Go: TOTP from RFC 6238, the 3-phase enrollment state machine, and recovery codes
The standard “you need a TOTP SDK” answer is wrong for Go: the entire TOTP standard (RFC 6238) is a function of time and a shared secret, computed with HMAC-SHA1 — and Go’s stdlib already ships crypto/hmac, crypto/rand, and encoding/base32. A complete 2FA implementation (secret generation, code generation, validation with a drift window, the *otpauth:// enrollment URI, the 3-phase setup/activate/commit state machine, and one-shot recovery *codes) fits in under 200 lines of stdlib Go. The thesis of this post is that the two hard parts of 2FA are not the crypto (the crypto is one HMAC *call) *— they are the state machine (a user who scans a QR but never activates must never be locked *out) and the recovery path (a user who loses their phone must not be gone for *good). The state machine and the recovery codes are where 2FA systems actually fail, and they are exactly the parts a SDK hides from you.
The three factors, and why 2FA is one factor plus one
Authentication is proving you are who you say you are, and there are three ways: **(1) something you know (a password or *PIN), *(2) something you have (a phone, a YubiKey, an RSA *token), *(3) something you are (a fingerprint, a *face). A password alone is weak because it can be phished; 2FA adds factor 2, a possession factor the attacker cannot phish. TOTP is that factor: the phone holds the shared secret and can produce a valid code.
The math: TOTP is one HMAC call against a counter that is time
The RFC 6238 formula is one line: OTP = Truncate(HMAC-SHA1(K, floor(currentTime / 30))). The counter is the 30-second time step as an 8-byte big-endian *number; the secret K is the 160-bit shared key exchanged once via the QR code; the Truncate is a 4-byte window of the HMAC digest. Both the server and the phone compute the same thing independently *— the only shared state is the secret. The entire totp function is the RFC verbatim:
| |
The subtle part of the math is the counter encoding: the RFC wants a big-endian uint64,* *and* *the* *for i, b := range* *loop* *is* *the* *idiomatic* *Go* *way* *to* *do* *that* *without* *a* *binary* *import.* *The* *o := int(sum[19] & 0xf)* *line* *is* *the* *dynamic* *truncation:* *the* *last* *nibble* *of* *the* *digest* *selects* *which* *4-byte* *window* *to* *read,* *and* *the* *&= 0x7fffffff* *drops* *the* *sign* *bit* *so* *the* *mod` is a true unsigned modulo.
The RFC as a flow — one HMAC per 30-second step, the truncation picking a 4-byte window, the mod giving the 6-digit code:
flowchart LR
T[Unix epoch seconds] -->|floor(t / 30)| C[8-byte big-endian counter]
K[160-bit shared secret K] --> H
C --> H[HMAC-SHA1 of the counter]
H --> D[20-byte digest]
D -->|last nibble picks a 4-byte window| W[31-bit integer]
W -->|mod 10^6| O[6-digit TOTP code]
style H fill:#1a3a1a
style O fill:#3a2a1a
The drift window: why a *±1 is not a nice to have
The single most common 2FA support ticket is *"my code was right!" The reason is clock drift: the phone’s clock may be a few seconds fast or slow relative to the server, and the 30-second step boundary is a hard edge (a code computed at 12:00:29.9 and validated at 12:00:30.1 is a different *step). The fix is a ±1 *window: check the current step, the previous step, and the next step, and accept any of the three:
| |
The design decision worth naming: the window is an integer count of 30-second steps, not a *time.Duration *— because the RFC defines the counter as *floor(time/30), and a Duration-based window would introduce a second unit of time into the math for no reason. The delta* *:=* *-int64(window);* *delta* *<=* *int64(window) loop is three candidates at window=1,* *and* *the* *continue* *on* *a* *totp* *error* *is* *defensive* (a *6-digit* *code* *cannot* *error* *here,* *but* *the* *digits out of range` path exists in the shared function and the loop should not crash on *it).
The 3-phase state machine: the part that prevents lockout
The hardest part of 2FA is not the math: it is the lifecycle. The classic failure mode: a user visits /2fa/setup,* *scans* *the* *QR,* *and* *their* *phone* *breaks* *before* *they* *type* *the* *code.* *If* *the* *system* *persisted* *the* *secret* *at* *setup* *time,* *the* *user* *is* *locked* *out* *forever* *by* *a* *secret* *they* *never* *proved* *they* *can* *read.* *The* *fix* *is* *a* *3-phase* *state* *machine:* **(1) *Setup* *— *generate* *the* *secret* *and* *hold* *it* *in* *a* *TTL* *cache,* *not* *the* *profile; *(2) *Activate* *— *the* *user* *types* *the* *code,* *and* *only* *if* *it* *validates* *is* *the* *secret* *committed* *to* *the* *profile; *(3) *Enabled` *— the login path checks the committed set and demands a code. The pending secret lives in a TTL cache for 10 minutes: if the user does not finish, it vanishes and the user is never locked out by a ghost secret:
| |
The two invariants the machine enforces: **(1) a pending secret is consumed exactly once (the delete before the Valid check makes a replayed Activate see no* *pending* *setup and *fail), *(2) a committed secret only exists after a valid code was seen. The delete before the math is the load-bearing ordering: a delete after the math lets a second concurrent Activate with the same code both succeed. The now seam is the only test hook: pinning the clock lets a test assert exact step boundaries without sleeping 30 seconds.
Note: The
nowseam is afunc() time.Timefield, not a package-level variable: the clock is a dependency of the state machine, and the machine is the unit under test. Thetime.Nowinjection is the only time a real wall clock is touched, which is why a test can assert exact step boundaries without sleeping 30 seconds.
Warning: The
pendingmap is an in-memory TTL cache in this model. In a multi-instance deployment (a load-balanced *pool), aSetupon one instance and anActivateon another will not find each other — the pending secret must live in a shared store (Redisor a KV *backend), not in process memory. The state machine is the same; thependingmap becomes aGet/Set/Deleteagainst the shared store. This is the one place where a single-instance 2FA implementation silently breaks when you scale out.
Recovery codes: the path for when the phone is gone
A phone breaks and a user loses it: a committed secret alone is not enough to log in. The recovery path is one-shot codes generated at activation (a 32-bit random value per *code), and the server stores only their sha256 hashes (the "store the hash, not the secret" discipline of a *password). Each code is used at most once — on a match it is deleted from the store, so a stolen recovery sheet is a one-time entry, not a permanent *backdoor:
| |
The two design decisions in this code: **(1) the stored value is the sha256 of the code, not the code (a leaked database does not leak the *codes); *(2) the delete on use is the one-shot guarantee — a replay of a consumed code fails because the entry is gone. The 32-bit per code is deliberately small: the security comes from the one-shot property, not the bit count.
The lifecycle is the state machine drawn below (a Setup proposes, an Activate proves,
the Enabled check is the login gate, and the recovery code is the " phone broke " *exit): a password that passes but a 2FA secret that is set must not issue an access token *— it issues a temp_token that is only valid for the /verify-2fa endpoint. A stolen password alone does not give you an access token.
The entire lifecycle, drawn as a state machine — the Setup proposes, the Activate proves, the Enabled check is the login gate, and the recovery code is the *"phone broke" exit:
stateDiagram-v2
[*] --> Setup: GET /2fa/setup (QR + recovery codes)
Setup --> Pending: secret in TTL cache (10 min)
Pending --> Activated: Activate (code validates in ±1 window)
Pending --> [*]: TTL expiry (no lockout: the secret vanished)
Activated --> Login: 2fa_required (a temp token, not an access token)
Login --> Verified: POST /verify-2fa (a fresh code)
Login --> Recovered: POST /login/recover (a one-shot code)
Verified --> [*]: access token
Recovered --> [*]: access token (the recovery code is deleted)
Why this is stdlib and not a SDK
The RFC is one HMAC call (crypto/hmac* with *sha1), the secret is a crypto/rand draw in base32,* *and* *the* *URI* *is* *url.Values* *— *all* *four* *in* *the* *stdlib.* *A* *go get* *for* *a* *totp` SDK is a dependency for a function you can write in 40 lines.
| Concern | SDK (third-party) | This post (stdlib) |
|---|---|---|
crypto/hmac + sha1 | wrapped | used directly |
crypto/rand for the secret | wrapped | used directly |
encoding/base32 | wrapped | used directly |
±1 drift window | varies, sometimes not exposed | explicit window parameter |
| 3-phase state machine | usually absent | the point of the design |
| one-shot recovery codes | usually absent | the point of the design |
| test seam for the clock | usually absent | now func() time.Time |
The table is the argument: the SDK gives you the math and hides the lifecycle — and the lifecycle is where 2FA systems fail. The stdlib gives you the math and leaves the lifecycle visible.
Takeaways
- *TOTP is one HMAC call: the RFC 6238 formula is
Truncate(HMAC-SHA1(K, floor(time/30)))*— thecrypto/hmacplussha1from the stdlib is the entire crypto, and thecounterencoding is an 8-byte big-endian write. No third-party TOTP SDK is required. - *The drift window is a
±1step loop: check the current step, the previous, and the next. A phone whose clock runs a few seconds fast is the most common 2FA failure, and the window is the fix. - The 3-phase state machine is the lockout prevention:
Setupholds a pending secret in a TTL cache (not the *profile);Activateconsumes the pending secret before checking the code (one concurrentActivate*wins);Enabledis the login check. A user who scans a QR but never activates is never locked out. - Recovery codes are one-shot and stored as hashes: the
sha256of the code is what the server keeps; thedeleteon use is the one-shot guarantee. A stolen recovery sheet is a one-time entry. The32-bitper code is deliberately small — the security is the one-shot, not the bit count.
Next steps
- Build the
Enrollmentstate machine with a real shared store forpending(aRedisor a KV *backend), so a load-balanced pool does not splitSetupandActivateacross instances. - Add the
temp_tokenbridge to your login handler: a password that passes with a committed 2FA secret issues a2fa_requiredstatus and a `temp_token, not an access token. - Print the recovery codes at activation and store only their hashes. A
sha256of a32-bithex code is the entire recovery store — no secrets in plaintext at rest. - Pin the clock in your tests via the
nowseam. A test that asserts a code from the previous step is accepted and one from two steps ago is not is the entire drift window test suite* — no sleeping 30 seconds required.