Building TOTP from Scratch in Go
A line-by-line walkthrough of RFC 6238
Every time you open Google Authenticator and watch a six-digit code slowly count down to its death like a mayfly with anxiety — that’s Time-Based One-Time Password (TOTP).
It’s one of the most widely deployed security mechanisms on the internet, and there’s a good chance you’ve typed one today without giving it a second thought.
The premise is almost annoyingly elegant: two parties share a secret key once, then independently derive the same short-lived code from that key and the current time. No network call. No central server playing puppet master. Both sides just agree on the math, and the math handles the rest.
TOTP is defined in RFC 6238 — back when “two-factor authentication” was still the kind of thing only banks and paranoid sysadmins cared about.
It’s an extension of HOTP (RFC 4226), which used an incrementing counter as the moving factor. HOTP had one unfortunate weakness: if you counter fell out of sync with the server’s, your codes stopped working and you’d be locked out, confused, and probably blaming your phone.
TOTP fixed this by replacing the counter with the Unix clock — something both sides always have access to, no synchronization required.
The big picture — how TOTP works end to end
Before we write a single line of code, let’s tell the full story of one login protected by TOTP. Two parties are invovled: the provider (your phone app, doing the math) and the verifier (the server, checking the math).
During account setup, the server generates a secret key K and shares it with your phone — usually via a QR code, because typing a 32-character Base32 string by hand is how you lose friends.
From that moment on, neither side ever transmits K again. It lives on your device and on the server, and that’s it.
When you log in:
- Your phone computes a 6-digit code from K and the current time
- You type it into the login form before it expires (no pressure)
- The server independently computes the same code from its copy of K and its clock
- If the match — welcome in. If they don’t — try again, and maybe sync your clock
Some things that are worth calling out here are:
- The prover never contacts the server to generate the code. It happens entirely offline
- The verifier checks three windows. The server computes OTPs for T-1, T, and T+1. This accounts for the time it took you to notice the code, switch apps, type it in, and hit submit. The RFC recommends olerating at most one step of drift — generous enough to be usable, tight enough to not be pointless as a security measure
- Replay protection is explicit and non-optional. Once a code is accepted, the server records that this time-step has been consumed for this user. Submit the same code twice in the same 30-second window and the second one gets rejected, even though it’s mathematically identical to the first. One-time means one time.
If you’ve made it so far I have to mention that for production systems you should use a vetted library. Rolling your own crypto is a cardinal sin in security circles, and for a good reason — the gap between “works” and “is actually secure” is full of subtle traps that have humbled people far smarter than either of us.
But there is a meaningful difference between shipping a hand-rolled crypto and understanding how it works by writing it once in a controlled setting. The latter is genuinely valuable, perhaps most importantly it will make yu confident explaining to someone else why TOTP is secure.
Chapter 1: The algorithm in plain English
The algorithm has five steps. They are, in order:
- Compute a time counter
- Encode it as bytes
- Feed it into an HMAC
- Truncate the result
- Extract a number
Each step is simple. The cleverness is in how they connect. Let’s walk through all of them.
Step 1: the formula
The entire algorithm fits in one line:
TOTP = Truncate(HMAC(K,T))Where:
- K — the shared secret key
- T — a number derived from the current time
- HMAC — keyed cryptographic hash
- Truncate — extraction procedure that turns the hash output in a human-typeable number
Where:
- CurrentUnixTime is the number of seconds elapsed since midnight Jan 1st, 1970 (UTC)
- T0 — is the starting point for counting. The RF defaults this to 0, meaning we count from the Unix epoch and you will rarely see it set to anything else
- X — is the time step — how many seconds each “window” lasts. The RFC recommends 30 seconds, and essentially everyone uses 30 seconds
The result is a single integer every 30 seconds, forever, for both sides simultaneously, without any communication between them.
There are two things worth internalising from this diagram:
- Any two timestamps within the same 30-second window produce the same T — and therefore the same OTP. This is by design. It’s what gives you time to read the code, switch apps, and type it in
- Crossing a window boundary produces a completely different OTP, not a similar one — HMAC is not continuous — a T of 41152263 and a T of 41152264 produce outputs with no detectable relationship to each other. Which is exactly what you want from a security primitive.
Step 2: encoding T as bytes
HMAC takes bytes as inputs, not integers. So T — which at this point is just a plain integer like 41152263 needs to be serialized into a byte sequence before we can do anything cryptographic with it.
The RFC specifies this precisely; T must be encoded as an 8-byte, big-endian, unsigned integer. No ambiguity, no options.
8 bytes because that’s what HOTP (the algorithm TOTP is built on) specifies for its counter field. It also future-proofs us past the year 2038, when Unix timestamps overflow a 32-bit integer and a lot of legacy software is going to have a VERY BAD day.
Big endian — the most significat byte comes first: the same byte order used in network protocols, which is why you’ll sometimes hear it called “network byte order”. The RFC mandates it, and both sides must agree or the codes won’t match
The output is 20 bytes (with SHA-1), 32 bytes (SHA-256), or 64 bytes (SHA-512).
Step 3: HMAC construction
You don’t need to memorise the HMAC construction — the standard library handles it. What matters is that the output has two properties that makes TOTP work:
- deterministic — the same key and time step always produce the same 20 bytes
- unpredictable — knowing the output for T=1000 tells you nothing useful about the output for T=1001 (This is what makes TOTP codes impossible to guess without a key)
Step 4: dynamic truncation
This is the most counterintuitive step, and the one with the best name. We have 20 bytes of HMAC output — a perfectly uniform blob of cryptographic noise. We need to turn it into something a human can type. The process is called dynamic truncation, and it works in two parts:
Part one: pick an offset — look at the last byte of the HMAC output and take its low 4 bits (values 0–15). This becomes an index — an offset — into the 20-byte array
Part two: extract 4 bytes — read 4 bytes starting from that offset. Mask the most significant bit to zero (to avoid any sign-related confusion when treating it as a positive integer). You now have a 31-bit number.
Why the last byte? Because it’s as unpredictable as any other part of the output — and using it to select which bytes to read means the offset itself changes with every new code. An attacker can’t predict which 4 bytes will be used without already knowing the HMAC output, which requires knowing the key.
Why mask the top bit? Because some languages treat large integers as negative if the top bit is set, and we want a clean, positive 31-bit integer regardless of platform. It costs us one bit of entropy and saves a category of implementation bugs.
Step 5: extract a number
For 6 digits: binary mody 1,000,000.
This keeps only the last 6 decimal digits of the number, discarding everything above. Then we left-pad with zeros to ensure the result is always exactly 6 characters long — because 000042 and 42 are very different things when a server is doing a string comparison.
The zero-padding step is easy to miss but critical. Without it, a code of 42 would never match an expected code of 000042, and you’d spend an afternoon debugging something that turns ut to be a string.TrimLeft call you shouldn’t have written.
The complete picture
Let’s put all five steps together in one narative pass, using a concrete example. Say the current Unix time is 59 seconds (the first test vector in the RFC — they kept it simple, lol):
- T = floor((59–0)/30)= floor(1.96) = 1
- T encoded as 8 bytes: [00][00][00][00][00][00][00][01]
- HMAC-SHA1(secret, [00 00 00 00 00 00 00 01]) — 20 bytes of output
- Last byte of HMAC — offset: extract 4 bytes there, mask top bit — 31-bit integer
- integer mod 100,000,000 — zero-pad to 8 digits — 94287082
The RFC lists this exact result in its test vectors. When we run our Go implementation and get 94287082 for T=1, we’ll know with certainty that we’ve implemented the RFC correctly. The IETF, unknowingly, is our continuous integration pipeline.
Some things we have not covered:
- The secret key (K) format — in real application, K isn’t a raw byte array sitting around — it’s typically a Base32-encoded string stored in a QR code. Decoding it before you can use it is a small but important step
- Verification and clock drift — the server’s job is trickier than the phone’s. It has to account for the fact that your phone’s clock might be slightly off, or that the code was generated two seconds before a window boundary
Chapter 2 — Project Setup
All we need is two files. One module. Let’s move.
mkdir totp && cd totp
go mod init totpYou should now see a ‘go.mod’ file appear with the module declaration. That’s the entire dependency manager setup.
### File structure
```
totp/
├── go.mod
├── totp.go ← the algorithm lives here
└── main.go ← wiring, output, manual testing- totp.go — wiill contain the generateOTP function and nothing else. It has no business knowing about fmt.Println or command-line arguments. It takes inputs, returns a string, stays in its lane
- main.go — is the entry point where we’ll call our function with the RFC’s test vectors and print the result
The imports we will need in totp.go:
package main
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"hash"
"math"
"time"
"fmt"
)
// generateTOTPand for main.go:
package main
import (
"crypto/sha1"
"fmt"
)
func main() {
secret := []byte("12345678901234567890")
fmt.Println(generateTOTP(secret, 8, 30, sha1.New))
}You will notice that we hardcode a secret as a plain ASCII string cast to []byte. This is fine for our purposes — it matches the test vectors in RFC Appendix B exactly, and our goal right now is correctness, not production-readiness.
In the real world, secrets arrive as Base32-encoded strings, usually via QR code during account setup. Decoding them into raw bytes before passing them to the generateTOTP is a separate concern we’ll handle later. For now, raw bytes keep the noise low while we focus on the algorithm.
Now implementing the TOTP:
// totp.go
package main
import (
"crypto/hmac"
"encoding/binary"
"fmt"
"hash"
"math"
"time"
)
func generateTOTP(secret []byte, digits int, step int64, algo func() hash.Hash) string {
// ── Step 1: Compute T ────────────────────────────────────────────────────
//
// time.Now().Unix() returns current time as seconds since Unix epoch (int64).
// Dividing by step (30) gives us which 30-second window we're currently in.
// Go integer division is an implicit floor() — no math.Floor needed.
//
// Example: Unix=1234567890, step=30 → T=41152263
// Example: Unix=59, step=30 → T=1
// Example: Unix=60, step=30 → T=2 (new window, new code)
T := time.Now().Unix() / step
// ── Step 2: Encode T as 8 bytes (big-endian) ────────────────────────────
//
// HMAC needs bytes, not an integer. RFC 4226 (which TOTP builds on)
// specifies the counter must be an 8-byte big-endian unsigned integer.
//
// make([]byte, 8) gives us a zeroed 8-byte slice.
// PutUint64 writes T into it in big-endian order (most significant byte first).
//
// Example: T=2 → [00 00 00 00 00 00 00 02]
// Example: T=41152263 → [00 00 00 00 02 74 08 C7]
msg := make([]byte, 8)
binary.BigEndian.PutUint64(msg, uint64(T))
// ↑
// T is int64, PutUint64 wants uint64.
// Safe cast — T is always positive.
// ── Step 3: Compute HMAC ─────────────────────────────────────────────────
//
// hmac.New(algo, secret) creates an HMAC instance keyed with our secret.
// algo is a function value — sha1.New, sha256.New, or sha512.New.
// It gets called internally by hmac.New to initialise the hash state.
//
// mac.Write(msg) feeds the encoded T into the HMAC as the message.
// mac.Sum(nil) finalises the computation and returns the raw bytes.
// Passing nil means "return the result, don't append it to anything".
//
// Output size depends on algo:
// SHA-1 → 20 bytes
// SHA-256 → 32 bytes
// SHA-512 → 64 bytes
mac := hmac.New(algo, secret)
mac.Write(msg)
h := mac.Sum(nil)
// ── Step 4: Dynamic Truncation ───────────────────────────────────────────
//
// We have 20–64 bytes of HMAC output. We need 4 bytes. Here's how we pick them:
//
// 1. Take the last byte of the HMAC output.
// 2. Mask it with 0x0F (keep only the low 4 bits) → gives a value 0–15.
// This is our offset — an index into the hash byte array.
// Using the last byte means the offset is itself unpredictable,
// since it's part of the HMAC output.
//
// Example: last byte = 0x0C → 0x0C & 0x0F = 12 → offset = 12
offset := h[len(h)-1] & 0x0F
// 3. Read 4 bytes starting at offset, interpret as a 32-bit big-endian uint.
// 4. Mask the most significant bit with 0x7FFFFFFF.
// This forces the top bit to 0, ensuring the result is always a
// positive integer regardless of platform or language sign handling.
// We lose one bit of entropy (31 bits instead of 32) — worth the safety.
binary32 := binary.BigEndian.Uint32(h[offset:offset+4]) & 0x7FFFFFFF
// ── Step 5: Extract OTP ──────────────────────────────────────────────────
//
// binary32 is a 31-bit integer — way too big to type at a login screen.
// Modulo 10^digits keeps only the last N decimal digits.
//
// digits=6 → mod 1,000,000 → value between 0 and 999,999
// digits=8 → mod 100,000,000 → value between 0 and 99,999,999
//
// math.Pow10(digits) returns a float64, so we cast to int for the modulo.
otp := int(binary32) % int(math.Pow10(digits))
// fmt.Sprintf with "%0*d":
// %d = format as integer
// 0 = pad with zeros (not spaces)
// * = take the width from the next argument (digits)
//
// This ensures "42" becomes "000042" for digits=6.
// Returning int would silently drop those leading zeros.
return fmt.Sprintf("%0*d", digits, otp)
}And main.go:
// ── Main: smoke test against RFC 6238 Appendix B test vectors ───────────────
//
// The RFC provides known input/output pairs we can verify against.
// If our output matches, the implementation is correct by definition.
// The IETF is our QA team.
package main
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/binary"
"fmt"
"hash"
"math"
)
func main() {
// Secret used in all RFC test vectors (ASCII, 20 bytes)
seed20 := []byte("12345678901234567890")
seed32 := []byte("12345678901234567890123456789012")
seed64 := []byte("1234567890123456789012345678901234567890123456789012345678901234")
// For testing we bypass time.Now() and pass T directly.
// We'll refactor generateTOTP to accept T as a parameter in Chapter 9
// when we write the full test suite. For now, just verify the logic.
fmt.Printf("SHA1 T=1: %s (expected: 94287082)\n", generateTOTPFromT(seed20, 8, sha1.New, 1))
fmt.Printf("SHA256 T=1: %s (expected: 46119246)\n", generateTOTPFromT(seed32, 8, sha256.New, 1))
fmt.Printf("SHA512 T=1: %s (expected: 90693936)\n", generateTOTPFromT(seed64, 8, sha512.New, 1))
}
// generateTOTPFromT is a testable variant that accepts T directly
// instead of reading time.Now(). Same logic, no clock dependency.
func generateTOTPFromT(secret []byte, digits int, algo func() hash.Hash, T int64) string {
msg := make([]byte, 8)
binary.BigEndian.PutUint64(msg, uint64(T))
mac := hmac.New(algo, secret)
mac.Write(msg)
h := mac.Sum(nil)
offset := h[len(h)-1] & 0x0F
binary32 := binary.BigEndian.Uint32(h[offset:offset+4]) & 0x7FFFFFFF
otp := int(binary32) % int(math.Pow10(digits))
return fmt.Sprintf("%0*d", digits, otp)
}All we have to do now is run it and see if the three test vectors from th RFC match.
❯ go run .
SHA1 T=1: 94287082 (expected: 94287082)
SHA256 T=1: 46119246 (expected: 46119246)
SHA512 T=1: 90693936 (expected: 90693936)This tells us that the implementation is correct. One thing to notice: generateTOTPFromT is not a workaround — it’s actually the better function signature for testing.
That’s all folks
We walked through RFC 6238, understood every step of the algorithm, and ended up with a working Go implementation that matches the IETF’s own test vectors.
![#[Pragmatic(Kiwi)]](https://miro.medium.com/v2/resize:fill:128:128/1*EiPLygmxkGZd3tUHOXwcgw.png)