DevToys Web Pro iconDevToys Web ProBlog
Přeloženo pomocí LocalePack logoLocalePack
Ohodnoťte nás:
Vyzkoušejte rozšíření pro prohlížeč:
← Back to Blog

TOTP and OTP Guide: How Authenticator Apps Generate 6-Digit Codes

11 min read

When you open Google Authenticator or Authy and see a 6-digit code ticking down every 30 seconds, you're looking at TOTP — Time-based One-Time Password. It's the offline floor of two-factor authentication: no SMS, no push notification, no internet connection required. Understanding how it works helps you implement it correctly and explain the security tradeoffs to your users. Follow along with the OTP Generator to generate and verify codes as you read.

How TOTP Works

TOTP is defined in RFC 6238, which builds on top of HOTP (HMAC-based One-Time Password, RFC 4226). The core idea is simple: both the server and the authenticator app share a secret key, and both can independently compute the same code for the current 30-second time window — no network communication needed at login time.

The algorithm in two sentences:

  1. Compute the current time counter: counter = floor(unix_timestamp / 30)
  2. Compute HMAC-SHA1(secret, counter), then extract 6 digits via dynamic truncation.

That's it. The server stores only the secret. The authenticator app stores only the secret. Neither stores codes — codes are derived on demand and expire after 30 seconds.

Step-by-Step Code Generation

Here is the full derivation. Understanding each step prevents mismatches when you implement it yourself.

Step 1 — Compute the counter. Take the current Unix timestamp in seconds and divide by 30:

counter = floor(1713600000 / 30) = 57120000

Step 2 — Encode the counter as 8-byte big-endian. HMAC requires a byte string, not a number:

counter_bytes = counter.to_bytes(8, 'big')  # b'\x00\x03oX\x80'

Step 3 — Compute HMAC-SHA1. Sign the counter bytes with the shared secret:

hmac_result = HMAC-SHA1(secret_bytes, counter_bytes)
# 20-byte result, e.g.:
# 1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a

Step 4 — Dynamic truncation. Take the last byte of the HMAC result and mask the lowest 4 bits to get an offset (0–15). Read 4 bytes starting at that offset and mask the top bit to get a 31-bit integer:

offset = hmac_result[19] & 0x0f          # e.g. offset = 10
four_bytes = hmac_result[offset:offset+4]  # 4 bytes at position 10
code_int = int.from_bytes(four_bytes, 'big') & 0x7fffffff  # mask top bit

Step 5 — Modulo 10^6. Reduce to 6 digits, zero-padded:

otp = code_int % 1_000_000  # e.g. 287082
otp_str = str(otp).zfill(6)  # "287082"

The zero-padding step is critical. Without zfill(6), a code like 7082 would display as 7082 instead of 007082, causing a mismatch on the server side.

Why 30 Seconds

The 30-second window is a deliberate balance between two competing concerns:

  • Security: A shorter window means a stolen code expires faster. An attacker who intercepts a TOTP code over a shoulder-surfing or phishing scenario has less time to use it.
  • Usability: A very short window (5 seconds) would mean codes expire before many users can type them, especially on mobile. A very long window (5 minutes) makes stolen codes far more dangerous.

RFC 6238 allows any period value via the period parameter in the otpauth URI, but 30 seconds has become the universal default. Some high-security deployments use 60 seconds for hardware tokens where the display refresh cycle is slower.

Secret Format: Base32, Not Base64

The shared secret is a random byte string — typically 160 bits (20 bytes) to match the SHA1 output length. But secrets are not stored as raw bytes. They are encoded as Base32 per RFC 4648.

Why Base32 instead of Base64? Three reasons:

  • Keyboard-friendly: Base32 uses only uppercase letters A–Z and digits 2–7. No lowercase, no +, no /, no = ambiguity in URL contexts.
  • No visual ambiguity: Base32 excludes characters that look alike in many fonts — 0 vs O, 1 vs l, 8 vs B. Users entering secrets manually make fewer transcription errors.
  • Case-insensitive: Implementations are required to accept both upper and lowercase input, further reducing entry errors.

A typical 160-bit secret encodes to 32 Base32 characters (with padding stripped):

secret_bytes = os.urandom(20)           # 20 random bytes
secret_b32   = base64.b32encode(secret_bytes).decode()
# e.g. "JBSWY3DPEHPK3PXP..."  (32 uppercase chars)

Implementations must strip or ignore the = padding characters that Base32 sometimes appends. A 20-byte secret produces exactly 32 Base32 characters before padding — so JBSWY3DPEHPK3PXP (no padding) and JBSWY3DPEHPK3PXP==== are equivalent.

The otpauth URI

Authenticator apps use a standardized URI format to receive secrets, typically delivered via QR code (see the QR Code Generator guide for how QR codes encode data). The format is:

otpauth://totp/{Issuer}:{account}?secret={BASE32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30

A real example:

otpauth://totp/Acme%20Corp:jane@acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme%20Corp&algorithm=SHA1&digits=6&period=30

Parameter breakdown:

ParameterValueNotes
secretBase32 stringRequired. The shared key.
issuerApp/service nameShown in authenticator UI. Must match path prefix.
algorithmSHA1SHA1 is universal. SHA256/SHA512 are supported but less compatible.
digits66 is standard. 8 is used by some banking apps.
period30Seconds per window. 30 is universal default.

The issuer appears twice — once as a prefix in the path (Issuer:account) and once as the issuer query parameter. Both must match. If they differ, some authenticator apps will show conflicting labels or reject the URI.

TOTP vs HOTP

TOTP (RFC 6238) is time-based. HOTP (RFC 4226) is counter-based — the counter increments each time a code is used rather than advancing with the clock.

PropertyTOTPHOTP
Counterfloor(time / period)Incremented on each use
Code validity30-second windowUntil used (or resynchronized)
Typical useAuthenticator apps, software tokensHardware tokens (YubiKey OTP mode)
Drift riskClock skewCounter desync if button pressed without use

HOTP is used in hardware tokens where there is no reliable clock. The token displays a new code each time a button is pressed. The server maintains a counter window (look-ahead) to handle cases where the user pressed the button multiple times without logging in.

Code Examples

Node.js with otplib (the most actively maintained TOTP library for Node):

npm install otplib
import { authenticator } from 'otplib';

// Generate a new secret
const secret = authenticator.generateSecret(); // e.g. "JBSWY3DPEHPK3PXP"

// Generate the current OTP code
const token = authenticator.generate(secret);
console.log(token); // e.g. "287082"

// Verify a user-submitted code (allows ±1 window tolerance)
const isValid = authenticator.verify({ token, secret });
console.log(isValid); // true or false

// Build the otpauth URI for QR code generation
const otpauthUrl = authenticator.keyuri(
  'jane@acme.com',  // account label
  'Acme Corp',      // issuer
  secret
);
// otpauth://totp/Acme%20Corp:jane%40acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme%20Corp&algorithm=SHA1&digits=6&period=30

Python with pyotp:

import pyotp

# Generate a new secret
secret = pyotp.random_base32()  # e.g. "JBSWY3DPEHPK3PXP"

# Generate current OTP
totp = pyotp.TOTP(secret)
print(totp.now())         # e.g. "287082"
print(totp.verify("287082"))  # True

# Build otpauth URI
uri = totp.provisioning_uri(name="jane@acme.com", issuer_name="Acme Corp")
# otpauth://totp/Acme%20Corp:jane%40acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme%20Corp

Browser (WebCrypto API) — no dependencies:

async function generateTOTP(base32Secret) {
  // Decode Base32 secret to bytes
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
  const clean = base32Secret.toUpperCase().replace(/=+$/, '');
  let bits = '';
  for (const char of clean) {
    bits += alphabet.indexOf(char).toString(2).padStart(5, '0');
  }
  const secretBytes = new Uint8Array(
    bits.match(/.{8}/g).map(b => parseInt(b, 2))
  );

  // Compute counter (current 30-second window)
  const counter = Math.floor(Date.now() / 1000 / 30);
  const counterBytes = new Uint8Array(8);
  new DataView(counterBytes.buffer).setBigUint64(0, BigInt(counter), false);

  // HMAC-SHA1
  const key = await crypto.subtle.importKey(
    'raw', secretBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
  );
  const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));

  // Dynamic truncation
  const offset = hmac[19] & 0x0f;
  const code = (
    ((hmac[offset]     & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) <<  8) |
     (hmac[offset + 3] & 0xff)
  ) % 1_000_000;

  return code.toString().padStart(6, '0');
}

TOTP vs SMS vs Push Notifications

All three are second factors, but they have very different threat models:

MethodWorks offlineKey attack vectorsPhishable
TOTPYesPhishing (real-time relay), device theftYes (real-time relay)
SMS OTPRequires cellularSIM-swap, SS7 interception, social engineering carrierYes
Push notificationNoPush fatigue (MFA bombing), app compromisePartially

SMS is the weakest link. SIM-swap attacks — where an attacker convinces a mobile carrier to transfer a victim's phone number — are documented and executed regularly against high-value targets. SS7 protocol weaknesses also allow interception at the network level. NIST SP 800-63B explicitly categorizes SMS OTP as a restricted authenticator.

Push fatigue (also called MFA bombing) is an attack where an adversary triggers repeated push notification prompts hoping the user approves one out of frustration. Number-matching (where the push notification displays a code the user must match to their screen) mitigates this.

TOTP is the offline floor: it works without a network, is not vulnerable to SIM-swap, and has no push fatigue vector. Its weakness is real-time phishing relay — a proxy site can forward a TOTP code to the real site within the 30-second window. Passkeys (FIDO2) solve this by binding the credential to the origin.

Backup Codes

Backup codes are single-use recovery codes issued at TOTP enrollment. They let users regain access if they lose their authenticator device.

Implementation requirements:

  • One-time use: Mark each code as used immediately upon successful verification. Never allow reuse.
  • Store hashed, not plaintext: Backup codes are secrets. Hash them with a password-hashing algorithm (bcrypt, Argon2, scrypt) before storing, exactly as you would a password. Plain storage means a database breach exposes all recovery codes.
  • Generate with sufficient entropy: 8–10 character random alphanumeric codes provide roughly 47–59 bits of entropy — adequate for single-use codes that are rate- limited.
  • Show once: Display backup codes only at generation time. After the user acknowledges them, they should not be retrievable from the UI — only replaceable.
  • Regenerate, do not extend: When a user requests new backup codes, invalidate all existing codes and issue a fresh set. Never append to an existing list.
import { randomBytes } from 'crypto';
import { hash } from 'bcrypt';

function generateBackupCodes(count = 8) {
  return Array.from({ length: count }, () =>
    randomBytes(5).toString('hex').toUpperCase() // e.g. "A3F2B1C4D5"
  );
}

async function storeBackupCodes(userId, codes) {
  const hashed = await Promise.all(
    codes.map(code => hash(code, 10))
  );
  // Store hashed codes in DB, linked to userId
  await db.backupCodes.createMany({
    data: hashed.map(h => ({ userId, codeHash: h, used: false })),
  });
}

async function verifyBackupCode(userId, submittedCode) {
  const stored = await db.backupCodes.findMany({
    where: { userId, used: false },
  });
  for (const row of stored) {
    const match = await compare(submittedCode, row.codeHash);
    if (match) {
      await db.backupCodes.update({ where: { id: row.id }, data: { used: true } });
      return true;
    }
  }
  return false;
}

Clock Drift Tolerance

TOTP depends on both parties having synchronized clocks. If the authenticator app's clock is behind or ahead of the server, the derived codes will differ.

The standard mitigation is to accept codes from adjacent windows. A tolerance of ±1 window means accepting the previous 30-second code and the next 30-second code in addition to the current one. This covers up to ±29 seconds of clock skew.

import { authenticator } from 'otplib';

// Accept codes from ±1 window (default in otplib)
authenticator.options = { window: 1 };

const isValid = authenticator.verify({ token, secret });

Widening the window beyond ±1 weakens security. A window of ±2 means a valid code can be up to 90 seconds old — enough time for a phishing relay attack to succeed more reliably. Keep tolerance at ±1 unless you have documented evidence of systemic clock issues.

Modern mobile devices sync via NTP and rarely drift significantly. If users report frequent authentication failures, the likely causes are: incorrect timezone settings on their device, manually set time, or a VM with unsynchronized guest clock.

Common Pitfalls

  • Base32 padding errors: Some libraries expect padded Base32 (length multiple of 8 with = characters). Others strip padding. Normalize to uppercase and strip padding before passing secrets to HMAC.
  • HMAC algorithm mismatch: The otpauth URI says algorithm=SHA1 but you initialize your library with SHA256. The server and app will generate different codes. Always verify the algorithm matches end-to-end. SHA1 remains the most compatible choice — many hardware authenticators do not support SHA256.
  • Missing zero-padding: A code of 7082 must be transmitted as 007082. Always use padStart(6, '0') or equivalent.
  • Issuer prefix conflicts: If two services use the same issuer name in the otpauth path, some authenticator apps will merge them visually or show only the last enrolled one. Use a distinctive, globally unique issuer name (your full product name, not just "App").
  • Reusing secrets across users: Every user must have their own randomly generated secret. Never derive secrets from usernames or other predictable values.
  • Not confirming enrollment: Always ask the user to enter a valid OTP code before completing TOTP setup. This confirms their app scanned the secret correctly before you disable their previous 2FA method.

Try the OTP Generator to generate TOTP secrets, compute live codes, and verify your implementation matches RFC 6238. For generating the QR code that delivers the otpauth URI to an authenticator app, see the QR Code Generator. For understanding HMAC signatures more broadly, see the HMAC and webhook signatures guide.