Skip to content

Instantly share code, notes, and snippets.

@aungkyawminn
Last active February 24, 2026 03:44
Show Gist options
  • Select an option

  • Save aungkyawminn/d89602d7dfb8c5fd0a0f9087b168d009 to your computer and use it in GitHub Desktop.

Select an option

Save aungkyawminn/d89602d7dfb8c5fd0a0f9087b168d009 to your computer and use it in GitHub Desktop.
2FA TOTP

TOTP RFC 6238 — eOTP Token Verification System

  • TOTP (RFC 6238) is just HOTP (RFC 4226) where the counter is derived from time.

1) The One-Sentence Idea

Both Server and Authenticator App share:

  • Same secret key
  • Same time
  • Same algorithm

So they generate the same OTP without internet.


2) Pairing Phase (Secret Sharing)

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

Mermaid — Pairing

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
Loading

3) HOTP vs TOTP (Where “Counter” Comes From)

HOTP (RFC 4226)

HOTP(secret, counter)

  • counter increases 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 (RFC 6238)

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?”

Mermaid — HOTP vs TOTP Counter Meaning

flowchart LR
subgraph HOTP
H1[Counter = 0,1,2,3...]
end

subgraph TOTP
T1["Counter = floor(Time / 30)"]
end
Loading

4) Why Internet Is Not Needed

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)

Mermaid — Both Sides Compute the Same OTP

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]
Loading

5) HOTP Algorithm Steps (What hotp() Does)

HOTP computes:

  1. Convert counter to 8-byte big-endian buffer
  2. Compute HMAC-SHA1(secret, counterBytes)
  3. Dynamic truncation to get a 31-bit integer
  4. otp = code % 10^digits
  5. pad with zeros to fixed length

Mermaid — HOTP Flow

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]
Loading

6) TOTP = HOTP with a Time-Based Counter (What verifyTotp() Does)

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.

Mermaid — TOTP Verification (Loop)

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]
Loading

7) “Counter” Explained with a Concrete Example

If:

  • period = 30
  • nowSeconds = 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.


8) JavaScript / Node.js Reference Code (Your Implementation + Helpers)

Below is a clean, self-contained reference you can drop into your project.

Notes:

  • This uses Node’s built-in crypto module.
  • secretBytes is a Buffer (decoded secret).
  • For TOTP, we compute counter = floor(nowSeconds / period).

8.1 HOTP (RFC 4226)

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");
}

8.2 Generate TOTP (RFC 6238) — just HOTP with time-based counter

/**
 * 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 };
}

8.3 Verify TOTP with drift tolerance (± windows)

/**
 * 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 };
}

8.4 (Optional) Quick Usage Example

// 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: ... }

9) Practical Notes

  • 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)

10) RFC References

  • RFC 4226 (HOTP)
  • RFC 6238 (TOTP)

Official TOTP RFC (IETF Datatracker):
https://datatracker.ietf.org/doc/html/rfc6238

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment