Applied Cryptography for Developers Module 11 – Tokens, OTPs and passkeys
HOTP and TOTP: one-time passwords done right
Compute HOTP and TOTP codes against the RFC test vectors, accept a clock window without allowing reuse, and keep OTP secrets and recovery codes safe.
What you will learn
- Compute HOTP and TOTP codes and check them against the test vectors of RFC 4226 and RFC 6238
- Verify a code inside a small time window and refuse a second use of the same step
- Store OTP secrets and recovery codes so that a copy of the database does not hand out working codes
- Explain why a one-time password can be relayed by a phishing page, and what to use against that
Before you start
What you’ll learn · Before you start
What you will learn
- Compute HOTP and TOTP codes and check them against the test vectors of RFC 4226 and RFC 6238
- Verify a code inside a small time window and refuse a second use of the same step
- Store OTP secrets and recovery codes so that a copy of the database does not hand out working codes
- Explain why a one-time password can be relayed by a phishing page, and what to use against that
Before you start
On this page
A six-digit code has only 1,000,000 values, so a careless login can be guessed, or can accept the same code twice. An app and a server both compute the code from a key they share, so the key itself never crosses the network at login, only a short code. HOTP: HMAC-based one-time password (RFC 4226). A short code worked out from a shared key and a counter that goes up with each use, by an HMAC-SHA-1 and a dynamic truncation., defined in RFC 4226, computes it from a counter that goes up with each use. TOTP: Time-based one-time password (RFC 6238). HOTP with the counter replaced by the number of time steps since 1970, 30 seconds each by default, so the code changes by itself., defined in RFC 6238, feeds the same function the number of 30-second steps since 1970, so the code changes by itself twice a minute.
Done right means exact arithmetic that passes the RFC test vectors, a small clock window, each code accepted once, a cap on wrong guesses, a key that is encrypted at rest and recovery codes that are stored as hashes. Even then a code is not phishing-resistant, because a look-alike page can pass a fresh code to the real site while it is valid.
HOTP: an HMAC of a counter, cut down to six digits
HOTP starts from a key that the app and the server both hold, 20 random bytes in the RFC’s examples, and a counter that both keep. The counter is written as 8 bytes, most significant first, and the key and the counter go through HMAC-SHA-1. The 20-byte result is too long to type, so RFC 4226 section 5.3 cuts it down in a step called dynamic truncation: The step of HOTP that cuts a 20-byte HMAC down to digits. The low 4 bits of the last byte give an offset, the 4 bytes there become a 31-bit number, and the code is that number modulo 10^6.. The low 4 bits of the last byte give an offset, the 4 bytes at that offset become a number of 31 bits, and the code is that number modulo 10^6 for six digits. The RFC requires at least six digits and allows seven or eight. Step through one code:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The counter. The first code uses counter 0. It is written as 8 bytes, most significant byte first, so counter 0 is eight zero bytes and counter 1 ends in 01.
- HMAC-SHA-1. The key and the counter go through HMAC-SHA-1. For counter 0 the result is cc93cf18 and so on to cde4b0: 20 bytes, far too long to type, so it is cut down.
- The offset. The last byte is 0xb0. Its low 4 bits are 0, so the offset is 0. The offset can be any number from 0 to 15, so the 4 bytes taken are not always the same ones.
- Four bytes, 31 bits. The 4 bytes at the offset are cc 93 cf 18. Clearing the top bit of the first (0xcc becomes 0x4c) leaves the 31-bit number 4c93cf18, which is 1284755224 and positive in every language.
- The last digits. The code is the number modulo 10^6, with leading zeros if needed: 1284755224 mod 1,000,000 = 755224, the first value in RFC 4226's table. Eight digits would give 84755224.
The program computes the ten codes of RFC 4226 Appendix D, whose key is the 20 characters 12345678901234567890, and sets
them beside the RFC’s table:
// HOTP of RFC 4226: an HMAC-SHA-1 of an 8-byte counter, cut down to a few decimal digits. The program computes the
// codes of Appendix D (secret "12345678901234567890", counters 0 to 9) and compares them with the RFC's table.
import { createHmac } from 'node:crypto';
const secret = Buffer.from('12345678901234567890', 'ascii'); // 20 bytes = 160 bits, the length RFC 4226 recommends
// The counter is an 8-byte big-endian integer.
function counterBytes(counter) {
const bytes = Buffer.alloc(8);
bytes.writeBigUInt64BE(BigInt(counter));
return bytes;
}
// Dynamic truncation (RFC 4226 section 5.3): the low 4 bits of the last byte are an offset into the 20 bytes; the 4 bytes
// there are read as a number with the top bit cleared (31 bits); the code is that number modulo 10^digits.
function truncate(mac, digits) {
const offset = mac[mac.length - 1] & 0x0f;
const number = mac.readUInt32BE(offset) & 0x7fffffff;
return { offset, number, code: String(number % 10 ** digits).padStart(digits, '0') };
}
const mac = (counter) => createHmac('sha1', secret).update(counterBytes(counter)).digest();
const hotp = (counter, digits = 6) => truncate(mac(counter), digits).code;
// One code, step by step.
const first = mac(0);
const t = truncate(first, 6);
console.log(`secret "12345678901234567890", ${secret.length} bytes (${secret.length * 8} bits)`);
console.log(`counter ${counterBytes(0).toString('hex')} (8 bytes, big-endian)`);
console.log(`HMAC-SHA-1 ${first.toString('hex')} (${first.length} bytes)`);
console.log(`offset ${t.offset} (the low 4 bits of the last byte, 0x${first.at(-1).toString(16)})`);
console.log(`31-bit number ${t.number.toString(16)} = ${t.number} (the first byte 0x${first[t.offset].toString(16)} with its top bit cleared is 0x${(first[t.offset] & 0x7f).toString(16)})`);
console.log(`6 digits ${t.code} (${t.number} mod 1,000,000)`);
console.log(`8 digits ${hotp(0, 8)} (the same number mod 100,000,000)`);
console.log('');
// RFC 4226 Appendix D, Table 2: the HOTP value for each count.
const published = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];
console.log('counter code RFC 4226 table same');
let all = true;
published.forEach((expected, counter) => {
const code = hotp(counter);
all &&= code === expected;
console.log(`${String(counter).padEnd(8)} ${code} ${expected} ${code === expected ? 'yes' : 'NO'}`);
});
console.log(`all ten codes equal the RFC's table: ${all}`);
console.log('');
// RFC 4226 section 5.4 gives the 20 bytes of one HMAC and works the truncation through by hand.
const sample = Buffer.from('1f8698690e02ca16618550ef7f19da8e945b555a', 'hex');
console.log(`section 5.4 sample: offset ${truncate(sample, 6).offset}, 4 bytes ${sample.subarray(10, 14).toString('hex')}, code ${truncate(sample, 6).code} (the RFC says 872921)`); Output
secret "12345678901234567890", 20 bytes (160 bits) counter 0000000000000000 (8 bytes, big-endian) HMAC-SHA-1 cc93cf18508d94934c64b65d8ba7667fb7cde4b0 (20 bytes) offset 0 (the low 4 bits of the last byte, 0xb0) 31-bit number 4c93cf18 = 1284755224 (the first byte 0xcc with its top bit cleared is 0x4c) 6 digits 755224 (1284755224 mod 1,000,000) 8 digits 84755224 (the same number mod 100,000,000) counter code RFC 4226 table same 0 755224 755224 yes 1 287082 287082 yes 2 359152 359152 yes 3 969429 969429 yes 4 338314 338314 yes 5 254676 254676 yes 6 287922 287922 yes 7 162583 162583 yes 8 399871 399871 yes 9 520489 520489 yes all ten codes equal the RFC's table: true section 5.4 sample: offset 10, 4 bytes 50ef7f19, code 872921 (the RFC says 872921)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hotp.mjs
All ten codes equal the RFC’s, and so does the hand-worked sample of section 5.4, which ends in the offset 10 and the code 872921. The app’s counter goes up each time a code is shown, whether or not the code is sent, so the server keeps its own counter and accepts a few values ahead of it, and RFC 4226 section 7.4 asks for that look-ahead to be as small as usability allows. TOTP calls this same function and changes only the counter that it passes in.
Checkpoint
Check: How an HOTP code is made
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does HOTP turn a key and a counter into a six-digit code?
HMAC-SHA-1 of the shared key (20 random bytes in the RFC) and the counter written as 8 bytes, most significant first. Dynamic truncation (RFC 4226 section 5.3) shortens the 20-byte result: the last 4 bits of its final byte are an offset, the 4 bytes starting there form a 31-bit number, which is reduced modulo 10^6 to give the code. The RFC requires at least six digits and allows seven or eight.
-
How do HOTP and TOTP differ?
HOTP (RFC 4226) computes the code from a counter that rises with every use. TOTP (RFC 6238) feeds the same function a count of 30-second steps since 1970, so the code changes by itself twice a minute. In both, the shared key never crosses the network at login, only a short code.
-
Which values check an HOTP implementation?
RFC 4226 Appendix D: the 20-character key 12345678901234567890 and the ten codes for counters 0 to 9, all of which matched. The hand-worked sample of section 5.4 ends in offset 10 and the code 872921.
-
Why does an HOTP server accept a few counter values ahead?
The app's counter goes up each time a code is shown, whether or not the code is sent, so the server keeps its own counter and accepts a few values ahead of it. RFC 4226 section 7.4 asks for that look-ahead to be as small as usability allows.
-
What does 'done right' mean for one-time passwords, and what do they still not stop?
Exact arithmetic that passes the RFC test vectors, a small clock window, each code accepted once, a cap on wrong guesses, a key encrypted at rest and recovery codes stored as hashes. Even then a code is not phishing-resistant: a look-alike page can pass a fresh code to the real site while it is valid.
TOTP: the same function with the clock as the counter Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Verifying a code: a window, one use and a limit on guesses
- Verify a time-based one-time password
- Secrets: how to make, show and store them
- Recovery codes: the way back in must not be the weak spot
- Why a one-time password can be relayed, and what to use instead
- Practise: compute, verify, store and refuse
- Interview questions
- How do HOTP and TOTP differ, and why does the server need the secret itself?
- A review of your TOTP login finds that it accepts ten steps either side, keeps no record of used codes, takes the time from the request and never stops anyone guessing. Explain what an attacker gains and rewrite the checks.
- A copy of your user database leaks. It holds password hashes, TOTP secrets and recovery-code hashes. Say what each one gives the finder, what you do in the first day, and what you change so that the next leak is smaller.
- Users who have TOTP switched on are still losing accounts to fake sign-in pages. Explain how that is possible with a correct, single-use verifier, and what you ship first, next and last.
- Key takeaways
2 diagrams · 4 runnable examples (JavaScript, Python) · 1 exercise with sample tests · 3 interview answers · about 46 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- Base32
- An encoding of bytes in the 32 characters A to Z and 2 to 7 (RFC 4648). It is case-insensitive, so it suits secrets that people read and type: 20 bytes make 32 characters.
- dynamic truncation
- The step of HOTP that cuts a 20-byte HMAC down to digits. The low 4 bits of the last byte give an offset, the 4 bytes there become a 31-bit number, and the code is that number modulo 10^6.
- HOTP
- HMAC-based one-time password (RFC 4226). A short code worked out from a shared key and a counter that goes up with each use, by an HMAC-SHA-1 and a dynamic truncation.
- otpauth link
- A link that begins otpauth:// and carries an OTP secret, an account name and settings to an authenticator app, usually inside a QR code.
- recovery code
- A single-use code that a person keeps offline and uses to get back into an account when the authenticator is lost. SP 800-63B-4 counts it as a look-up secret.
- time step
- The length of time one TOTP code lasts, 30 seconds by default. The step number is the Unix time divided by the step length, rounded down.
- TOTP
- Time-based one-time password (RFC 6238). HOTP with the counter replaced by the number of time steps since 1970, 30 seconds each by default, so the code changes by itself.
References
- RFC 4226: HOTP, an HMAC-Based One-Time Password Algorithm (Internet Engineering Task Force (IETF))
- RFC 6238: TOTP, Time-Based One-Time Password Algorithm (Internet Engineering Task Force (IETF))
- RFC 4648: The Base16, Base32, and Base64 Data Encodings (Internet Engineering Task Force (IETF))
- NIST SP 800-63B-4: Digital Identity Guidelines, Authentication and Authenticator Management (National Institute of Standards and Technology (NIST))
- OWASP Application Security Verification Standard 5.0, V6 Authentication (requirements 6.4.3, 6.4.4, 6.5.1 to 6.5.8 and 6.6.1) (OWASP Foundation)
- Key URI Format: the otpauth links that authenticator apps read (Authenticator open-source project (archived wiki))
- Node.js 24 documentation: crypto (createHmac and timingSafeEqual) (OpenJS Foundation (Node.js))
- Python documentation: hmac and hashlib (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress