- TOTP (RFC 6238) is just HOTP (RFC 4226) where the counter is derived from time.
Both Server and Authenticator App share:
- Same secret key
- Same time
- Same algorithm
So they generate the same OTP without internet.
This happens once:
- Server generates a Secret
- Server shows a QR code that contains the secret (usually Base32 encoded)
- User scans it with Authenticator
- Authenticator stores it locally
sequenceDiagram
participant Server
participant User
participant Authenticator
Server->>User: Show QR (contains Secret)
User->>Authenticator: Scan QR
Authenticator->>Authenticator: Save Secret locally
Note over Server,Authenticator: Both now share the same Secret
HOTP(secret, counter)
counterincreases when you use/generate a new OTP- Example counters: 0, 1, 2, 3, …
- Client and Server must keep the counter in sync (harder to manage)
TOTP(secret) = HOTP(secret, counter)
But counter is computed from time:
counter = floor( unixTimeSeconds / periodSeconds )- period is usually 30 seconds
So TOTP’s “counter” means:
“Which 30-second time slot are we in since Unix epoch?”
flowchart LR
subgraph HOTP
H1[Counter = 0,1,2,3...]
end
subgraph TOTP
T1["Counter = floor(Time / 30)"]
end
Because the OTP is not “sent” from server to phone.
Instead, both compute the OTP from:
- secret (stored locally)
- time (from device clock)
- algorithm (HMAC + truncation + digits)
flowchart LR
subgraph Client["Authenticator App (Offline)"]
A1[Secret]
B1[Time]
C1[Compute OTP]
D1[OTP: 482913]
end
subgraph Server["Server"]
A2[Secret]
B2[Time]
C2[Compute OTP]
D2[OTP: 482913]
end
D1 --> X{Compare}
D2 --> X
X -->|match| OK[Valid]
X -->|no match| NO[Invalid]
HOTP computes:
- Convert
counterto 8-byte big-endian buffer - Compute
HMAC-SHA1(secret, counterBytes) - Dynamic truncation to get a 31-bit integer
otp = code % 10^digits- pad with zeros to fixed length
flowchart TD
S[secretBytes] --> HMAC
C[counter number] --> CB[Convert counter to 8-byte buffer]
CB --> HMAC["HMAC-SHA1(secret, counterBuf)"]
HMAC --> HASH[20-byte hash]
HASH --> OFF[offset = lastByte & 0x0f]
OFF --> TRUNC[Take 4 bytes from hash at offset]
TRUNC --> CODE[Build 31-bit integer]
CODE --> MOD[code % 10^digits]
MOD --> PAD["padStart(digits)"]
PAD --> OTP[Return OTP string]
TOTP derives the counter from time:
currentCounter = floor(nowSeconds / period)
Then it verifies by checking a small window for clock drift:
- previous counter:
currentCounter - 1 - current counter:
currentCounter - next counter:
currentCounter + 1
This is what your driftWindows does.
flowchart TD
A[Input: otp + nowSeconds] --> B["currentCounter = floor(nowSeconds / period)"]
B --> C{delta = -drift..+drift}
C --> D[counter = currentCounter + delta]
D --> E["candidate = HOTP(secret, counter)"]
E --> F{candidate == otp?}
F -- yes --> G[valid=true, matchedCounter=counter]
F -- no --> C
C -->|all tried| H[valid=false]
If:
period = 30nowSeconds = 1710000037
Then:
currentCounter = floor(1710000037 / 30)= floor(57000001.233...)= 57000001
That number (57 million-ish) is the time slot index since 1970.
So both sides calculate the same time slot → same OTP.
Below is a clean, self-contained reference you can drop into your project.
Notes:
- This uses Node’s built-in
cryptomodule.secretBytesis aBuffer(decoded secret).- For TOTP, we compute
counter = floor(nowSeconds / period).
import { createHmac } from "crypto";
/**
* Generate HMAC-SHA1 based OTP (RFC 4226 - HOTP)
* @param {Buffer} secretBytes
* @param {number} counter
* @param {number} digits
* @returns {string}
*/
export function hotp(secretBytes, counter, digits = 6) {
// HOTP requires an 8-byte counter (big-endian)
const counterBuf = Buffer.alloc(8);
const high = Math.floor(counter / 0x100000000);
const low = counter >>> 0;
counterBuf.writeUInt32BE(high, 0);
counterBuf.writeUInt32BE(low, 4);
// HMAC-SHA1(secret, counterBuf)
const hmac = createHmac("sha1", secretBytes);
hmac.update(counterBuf);
const hash = hmac.digest(); // 20 bytes
// Dynamic truncation
const offset = hash[hash.length - 1] & 0x0f;
const code =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// Reduce to digits + left-pad
const otp = code % Math.pow(10, digits);
return String(otp).padStart(digits, "0");
}/**
* Generate TOTP (RFC 6238) using HOTP building block
* @param {Buffer} secretBytes
* @param {number} nowSeconds e.g. Math.floor(Date.now() / 1000)
* @param {number} period usually 30 seconds
* @param {number} digits usually 6
* @returns {{ otp: string, counter: number }}
*/
export function generateTotp(secretBytes, nowSeconds, period = 30, digits = 6) {
const counter = Math.floor(nowSeconds / period);
const otp = hotp(secretBytes, counter, digits);
return { otp, counter };
}/**
* Verify TOTP with ±driftWindows tolerance
* @param {Buffer} secretBytes
* @param {string} otp
* @param {number} nowSeconds
* @param {number} period
* @param {number} digits
* @param {number} driftWindows
* @returns {{ valid: boolean, matchedCounter: number|null }}
*/
export function verifyTotp(
secretBytes,
otp,
nowSeconds,
period = 30,
digits = 6,
driftWindows = 1
) {
const currentCounter = Math.floor(nowSeconds / period);
for (let delta = -driftWindows; delta <= driftWindows; delta++) {
const counter = currentCounter + delta;
const candidate = hotp(secretBytes, counter, digits);
if (candidate === otp) {
return { valid: true, matchedCounter: counter };
}
}
return { valid: false, matchedCounter: null };
}// Example usage
const nowSeconds = Math.floor(Date.now() / 1000);
// secretBytes must be a Buffer of your secret.
// (Usually you'll decode Base32 first if you store secrets in Base32.)
const { otp, counter } = generateTotp(secretBytes, nowSeconds);
console.log("TOTP:", otp, "counter:", counter);
const result = verifyTotp(secretBytes, otp, nowSeconds, 30, 6, 1);
console.log(result); // { valid: true, matchedCounter: ... }- TOTP works offline because both sides can compute the same time counter.
- If clocks drift, verification can fail. The drift window helps.
- For real systems, you should:
- Use constant-time compare (
timingSafeEqual) to reduce timing leaks - Rate-limit OTP attempts per user/session
- Store secrets encrypted at rest
- Consider replay protection for transaction approvals (bind OTP to challenge/session if needed)
- Use constant-time compare (
- RFC 4226 (HOTP)
- RFC 6238 (TOTP)
Official TOTP RFC (IETF Datatracker):
https://datatracker.ietf.org/doc/html/rfc6238