Crypto recipe
Time-based one-time passwords (TOTP)
Generate and verify the 6-digit rotating codes from authenticator apps (RFC 6238).
A time-based one-time password is the rotating 6-digit code in Google Authenticator, Authy, or 1Password. It is an HMAC over a counter that ticks every 30 seconds, so a server and an app that share one secret independently compute the same code without ever talking. This is RFC 6238 (TOTP), built on RFC 4226 (HOTP).
Compute it: divide the Unix time by the 30-second step to get an 8-byte counter, HMAC that with the shared secret, then apply RFC 4226 'dynamic truncation' to fold the MAC down to a 6-digit number. To check a code a user typed, compute it for the current step (and the adjacent ones, for clock skew) and compare in constant time.
Get it right
- Use HMAC-SHA1 by default — it is the interop standard every authenticator app supports. SHA-1 is safe inside HMAC; TOTP does not rely on its collision resistance.
- Authenticator secrets are shared as base32 (the QR code). Decode them to raw bytes before computing the HMAC.
- Generate a fresh high-entropy secret per user (≥ 20 random bytes) with a CSPRNG.
- On verification accept the current 30-second step ±1 for clock skew, and compare codes in constant time.
- Rate-limit attempts and reject a code already used in its window — TOTP alone does not stop online guessing.
Implementation
Setup Standard library (crypto/hmac, crypto/sha1).
package main
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"fmt"
)
func main() {
secret := []byte("12345678901234567890")
var unix int64 = 59 // RFC 6238 test vector -> 287082; use time.Now().Unix() live
// Counter = Unix time / 30-second step, as 8 big-endian bytes.
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(unix/30))
mac := hmac.New(sha1.New, secret) // SHA-1 is the TOTP interop default
mac.Write(msg[:])
sum := mac.Sum(nil)
// Dynamic truncation (RFC 4226) -> a 6-digit code.
offset := sum[len(sum)-1] & 0x0f
bin := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
code := fmt.Sprintf("%06d", bin%1_000_000)
if code != "287082" {
panic("TOTP mismatch")
}
fmt.Println(code)
} Authenticator secrets are base32 — decode with encoding/base32 to get the key bytes before hashing.
Setup Standard library (hmac, hashlib, struct).
import hmac, hashlib, struct
secret = b"12345678901234567890"
unix = 59 # RFC 6238 test vector -> 287082; use int(time.time()) live
counter = struct.pack(">Q", unix // 30) # 8 big-endian bytes
mac = hmac.new(secret, counter, hashlib.sha1).digest() # SHA-1: the TOTP default
offset = mac[-1] & 0x0F # dynamic truncation (RFC 4226)
bin = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
code = str(bin % 1_000_000).zfill(6)
assert code == "287082"
print(code) Setup Built in (node:crypto).
import { createHmac } from 'node:crypto';
const secret = Buffer.from('12345678901234567890');
const unix = 59; // RFC 6238 test vector -> 287082; use Math.floor(Date.now() / 1000) live
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(unix / 30))); // 8 big-endian bytes
const mac = createHmac('sha1', secret).update(counter).digest(); // SHA-1: the TOTP default
const offset = mac[mac.length - 1] & 0x0f; // dynamic truncation (RFC 4226)
const bin = mac.readUInt32BE(offset) & 0x7fffffff;
const code = String(bin % 1_000_000).padStart(6, '0');
if (code !== '287082') throw new Error('TOTP mismatch');
console.log(code); In the browser, WebCrypto signs with crypto.subtle.sign({ name: "HMAC", hash: "SHA-1" }, ...).
Setup Built in (System.Security.Cryptography.HMACSHA1).
using System.Security.Cryptography;
using System.Text;
byte[] secret = Encoding.UTF8.GetBytes("12345678901234567890");
long unix = 59; // RFC 6238 test vector -> 287082; use DateTimeOffset.UtcNow.ToUnixTimeSeconds() live
byte[] counter = BitConverter.GetBytes((ulong)(unix / 30));
if (BitConverter.IsLittleEndian) Array.Reverse(counter); // 8 big-endian bytes
byte[] mac = HMACSHA1.HashData(secret, counter); // SHA-1: the TOTP default
int offset = mac[^1] & 0x0f; // dynamic truncation (RFC 4226)
int bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16)
| (mac[offset + 2] << 8) | mac[offset + 3];
string code = (bin % 1_000_000).ToString("D6");
if (code != "287082") throw new Exception("TOTP mismatch");
Console.WriteLine(code); Setup OpenSSL (<openssl/hmac.h>) — libsodium has no HMAC-SHA1.
#include <openssl/hmac.h>
#include <cstdint>
#include <cstring>
#include <cstdio>
int main() {
const unsigned char *secret = (const unsigned char *)"12345678901234567890";
uint64_t unixTime = 59; // RFC 6238 test vector -> 287082; use time(nullptr) live
unsigned char msg[8]; // counter = Unix time / 30, big-endian
uint64_t counter = unixTime / 30;
for (int i = 7; i >= 0; --i) { msg[i] = counter & 0xff; counter >>= 8; }
unsigned char mac[EVP_MAX_MD_SIZE];
unsigned int maclen = 0;
HMAC(EVP_sha1(), secret, 20, msg, sizeof msg, mac, &maclen); // SHA-1: the TOTP default
int offset = mac[maclen - 1] & 0x0f; // dynamic truncation (RFC 4226)
uint32_t bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16)
| (mac[offset + 2] << 8) | mac[offset + 3];
char code[7];
std::snprintf(code, sizeof code, "%06u", bin % 1000000u);
std::printf("%s\n", code);
return std::strcmp(code, "287082") == 0 ? 0 : 1;
} The one-shot HMAC() is fine; OpenSSL 3 only deprecates the streaming HMAC_CTX API.
Setup Built in (javax.crypto.Mac with HmacSHA1).
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
byte[] secret = "12345678901234567890".getBytes();
long unix = 59; // RFC 6238 test vector -> 287082; use Instant.now().getEpochSecond() live
byte[] msg = ByteBuffer.allocate(8).putLong(unix / 30).array(); // 8 big-endian bytes
Mac mac = Mac.getInstance("HmacSHA1"); // SHA-1: the TOTP default
mac.init(new SecretKeySpec(secret, "HmacSHA1"));
byte[] h = mac.doFinal(msg);
int offset = h[h.length - 1] & 0x0f; // dynamic truncation (RFC 4226)
int bin = ((h[offset] & 0x7f) << 24) | ((h[offset + 1] & 0xff) << 16)
| ((h[offset + 2] & 0xff) << 8) | (h[offset + 3] & 0xff);
String code = String.format("%06d", bin % 1_000_000);
if (!code.equals("287082")) throw new RuntimeException("TOTP mismatch");
System.out.println(code); Setup Cargo.toml: hmac = "0.12", sha1 = "0.10".
use hmac::{Hmac, Mac};
use sha1::Sha1;
fn main() {
let secret = b"12345678901234567890";
let unix: u64 = 59; // RFC 6238 test vector -> 287082; use SystemTime live
let counter = (unix / 30).to_be_bytes(); // 8 big-endian bytes
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap(); // SHA-1: the TOTP default
mac.update(&counter);
let h = mac.finalize().into_bytes();
let offset = (h[h.len() - 1] & 0x0f) as usize; // dynamic truncation (RFC 4226)
let bin = u32::from_be_bytes([h[offset], h[offset + 1], h[offset + 2], h[offset + 3]])
& 0x7fff_ffff;
let code = format!("{:06}", bin % 1_000_000);
assert_eq!(code, "287082");
println!("{}", code);
}