Skip to content
Gaurav Singh

TOTP: Why the Authenticator Code Is Right Even When Your Phone Is Offline

Security 11 min read

You land in another country, swap in a local SIM, and sign in to your social media account from the hotel’s laptop. It offers to text a two-factor OTP to the number. The code is generated, but you will never receive it.

An SMS code is not something you know — it is something that has to be delivered, to one specific SIM, over one specific network. Break either link and the code never arrives.

home network another country Auth server sends the code on time 366845 SMS in flight never arrives no signal Your phone, abroad SIM swapped out · no roaming
The server does its part. The code stops at the network you are no longer on.

That is the failure nobody plans for: the 2FA meant to protect the account is the thing locking you out of it. Lose the SIM, the signal, or the roaming, and you lose every account that texts you a code.

The usual fallback is “send it to my email instead”. That only swaps the cellular network for Wi-Fi. The code is still made on the server, and it still has to travel to you — so with no internet, you are just as stuck.

Email also adds a problem SMS does not have. Think about what happens when you forget your password: you click “forgot password”, and the reset link lands in your email. Your mailbox can already unlock the account on its own.

Now send the OTP there as well. The password and the code sit behind the same mailbox, so anyone who gets into your email can reset one and read the other. You think there are two locks on the door. They open with the same key.

And mailboxes are the most attacked thing on the internet: fake login pages, passwords leaked from other sites, stolen sessions.

Time-based One-Time Password (RFC 6238) inverts the model. There is no code in flight, because there is no code until each side computes one. Both sides hold the same two inputs and run the same function:

  • a shared secret, agreed once during enrollment and never transmitted again;
  • the current time, which both sides already have, and which nobody has to send because the universe delivers it for free.
Authenticator app airplane mode · no SIM · no Wi-Fi secret = JBSWY3DP… (stored once) T = 09:41:37 UTC → step 59631563 366845 Verification server a continent away, same instant secret = JBSWY3DP… (stored once) T = 09:41:41 UTC → step 59631563 366845 no packets cross this line during code generation
Same inputs, same function, same output — computed twice, independently.

Two things have to be true for that to work: both sides must agree on the secret, and both must agree on the time. The next two sections are those two halves.

If you would rather poke at this than read about it, there is a live demo at totp.gauravnegi.in. It generates a secret, shows the QR code to scan with your own authenticator app, rolls the six digits every thirty seconds, and checks the codes your phone produces. Everything described from here on is visible on that one page — it is worth keeping open in a second tab.

Everything TOTP does later depends on a single exchange that happens once.

Server Authenticator app 1. generate 160 random bits secret = 48 65 6c 6c 6f 21 … 2. otpauth:// URI, rendered as a QR code (over TLS, shown once) otpauth://totp/Social:you@mail.com?secret=JBSWY3DP…&issuer=Social&period=30 3. store the secret in the keystore — it never leaves the device again 4. one generated code, to prove both sides stored the same secret 5. mark enrollment confirmed, issue recovery codes
The secret crosses the wire exactly once. Every later authentication is a local computation on both ends.

A few details carry more weight than they look like they do:

  • The secret is random, not derived. 160 bits from a CSPRNG, Base32-encoded because QR alphanumeric mode handles uppercase and digits compactly. Do not derive it from the user id, the email, or anything else guessable.
  • The QR code is a transport, not a security boundary. Anyone who photographs that screen has a permanent second factor. Show it once, never re-display it, and rate-limit re-enrollment.
  • Step 4 is not a formality. It is the only proof that the phone actually scanned the right thing before you switch the account into “2FA required” mode. Skip it and you will lock users out with a silently mis-scanned secret.
  • Recovery codes belong here. TOTP has no side channel — that is the point — so a lost device is unrecoverable without them.

Server-side, the secret must be encrypted at rest (envelope encryption with a KMS key, not a column in plaintext). Unlike a password, it cannot be hashed: the server needs the original value to compute the expected code. A leaked secrets table is a leaked second factor for every user in it.

TOTP is HOTP — a counter-based OTP — with the counter defined as elapsed time instead of a click count. That one substitution is what removes the need to synchronise anything.

The counter is just the clock divided into 30-second blocks: take Unix time, divide by 30, throw away the remainder. Everyone on Earth who does that at the same moment gets the same number.

Take a real instant — 2026-09-09 09:41:37 UTC, which is Unix time 1788946897. Divided by 30 that is step 59631563, and it stays 59631563 until the clock rolls past 09:42:00. Here is what happens to it, with the real bytes for the example secret JBSWY3DPEHPK3PXP:

unix time — both sides just read their own clock 1788946897 divide by 30, drop the remainder time step — the same number for a whole 30 seconds 59631563 as 8 bytes, big-endian: 00 00 00 00 03 8d e7 cb HMAC-SHA1(secret, step) — the secret never moves 20 bytes come out. Only two positions matter: 0 21 1 81 2 1d 3 f8 4 52 5 c0 6 6f 7 1f 8 fa 9 fd 10 47 11 c9 12 ac 13 c3 14 65 15 f6 16 2a 17 34 18 f0 19 d6 The last byte, d6, says where to look: d6 & 0x0f = 6, so read the four bytes at 6, 7, 8, 9 6f 1f fa fd read as one number, top bit cleared = 1,864,366,845 keep the last 6 digits 366845
The only surprising step is the middle one: the digest picks its own slice of itself.

In code, unabbreviated:

import { createHmac } from 'node:crypto';
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// The secret arrives base32-encoded, and JS has no built-in decoder:
// read 5 bits per character, emit a byte every time 8 have accumulated.
function base32Decode(secret) {
let value = 0;
let bits = 0;
const bytes = [];
for (const char of secret.toUpperCase().replace(/=+$/, '')) {
const index = BASE32.indexOf(char);
if (index === -1) throw new Error(`not base32: ${char}`);
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((value >> bits) & 0xff);
}
}
return Buffer.from(bytes);
}
// The code for one specific time step.
function totpAt(secretB32, counter, digits = 6) {
const message = Buffer.alloc(8);
message.writeBigUInt64BE(BigInt(counter)); // 8 bytes, big-endian
const mac = createHmac('sha1', base32Decode(secretB32)).update(message).digest();
const offset = mac[mac.length - 1] & 0x0f; // 0..15, so 4 bytes always fit
const chunk = mac.readUInt32BE(offset) & 0x7fff_ffff;
return String(chunk % 10 ** digits).padStart(digits, '0');
}
function totp(secretB32, { at = Date.now() / 1000, period = 30, digits = 6 } = {}) {
return totpAt(secretB32, Math.floor(at / period), digits);
}
totp('JBSWY3DPEHPK3PXP', { at: 1788946897 }); // '366845'

Three design choices in there are worth naming:

HMAC, not a hash of the concatenation. HMAC’s nested construction is what keeps an attacker who collects thousands of codes from learning anything about the secret. Each code exposes 20 bits of a 160-bit MAC output, and the MAC is one-way with respect to the key.

Dynamic truncation with a variable offset. The four bytes are not taken from a fixed position — the position is itself derived from the MAC. This spreads the extracted bits across the whole digest across different counters, so no fixed slice of the MAC is under repeated observation. The & 0x7FFFFFFF clears the sign bit, purely so that languages with signed 32-bit integers agree on the value.

The modulo is deliberate lossiness. Six digits out of a 31-bit number is a ~7.6-fold reduction; codes are meant to be typed, and the security budget comes from the 30-second lifetime plus server-side rate limiting, not from the entropy of the digits. This is why “lock the account after five failed attempts” is not optional — without it, six digits at high request rates is genuinely brute-forceable.

Two things make an offline device produce the value the server expects.

The secret is state that was synchronised once and never changes. It is not a session, not a token, not something with a lifetime. Enrollment copied it; both sides keep it forever. Nothing needs to re-synchronise because nothing evolves.

Time is a counter that is already replicated everywhere. This is the elegant part. HOTP’s original counter is a shared mutable variable — every code generated on the device advances it, and if the device advances it without the server seeing the result, the two drift apart and you need a resynchronisation window. TOTP replaces that mutable variable with floor(unix_time / 30): a value every correct clock on Earth computes identically, that no participant can advance, and that nobody has to tell anyone about. The replication problem was not solved. It was deleted.

Unix time helps twice over, because it counts seconds since an epoch in UTC. Time zones, DST, and the calendar are display concerns layered on top; a phone flown from Delhi to Frankfurt shows a different wall clock but the same unix_time. That is why crossing borders changes nothing.

What remains is drift, and it is bounded and mundane: phones sync to NTP or to the cellular network, servers to NTP, and both stay within a second or two. Two things absorb the rest:

…562 830455 …563 366845 …564 859618 …565 rejected 09:41:00 09:41:30 09:42:00 09:42:30 server accepts step ± 1 tolerates ~30 s of clock drift and slow typing outside the window → reject, count the failure
A ±1 step window is the standard trade: 90 seconds of validity for a workable amount of drift.

The demo prints the previous and next codes either side of the current one, which makes the window concrete: at any given moment, all three are numbers the server would accept.

The second absorber is per-user drift tracking. If a user’s codes consistently verify one step behind, store that offset (-1) on their record and centre the window on it. A phone with a chronically slow clock keeps working, and you keep the window narrow instead of widening it for everyone.

Resist the urge to widen it. Every extra step you accept multiplies the brute-force surface and lengthens the replay window linearly. ±1 is the norm; ±2 is defensible for known-bad populations; anything beyond that is a rate-limiting problem wearing a clock-drift costume.

Which is the whole difference with sections 1 and 2. The phone is offline the entire time it generates the code. The only thing that ever travels is the six digits you type into the login form — and those go over the connection you are already using to log in. There is no second channel to fail, no third party to compromise, and nothing for a carrier to misroute.

The server side is short, and every line that looks optional is not:

import { timingSafeEqual } from 'node:crypto';
const sameCode = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
async function verify(user, submitted, window = 1) {
if (!rateLimiter.allow(user.id)) throw new TooManyAttempts(); // before any crypto
const step = Math.floor(Date.now() / 1000 / 30) + user.drift;
for (let offset = -window; offset <= window; offset++) {
if (sameCode(totpAt(user.secret, step + offset), submitted)) {
if (step + offset <= user.lastUsedStep) return false; // replay guard
user.lastUsedStep = step + offset;
user.drift += offset; // learn the drift
await user.save();
return true;
}
}
rateLimiter.recordFailure(user.id);
return false;
}
  • timingSafeEqual, not ===. String comparison short-circuits on the first mismatched character. Over enough attempts that timing difference leaks the code prefix by prefix.
  • lastUsedStep is the replay guard. Without it, a code stays valid for the rest of its window — long enough for someone reading over your shoulder, or a proxy sitting in the middle, to reuse it. Burn each step once per user.
  • Rate limiting comes first. Six digits is ~20 bits. At a few thousand attempts per second and a 90-second window, that is not a comfortable margin. Limit per user and per IP, and lock out on sustained failure.

Being honest about the boundary matters, because “I have 2FA” is doing a lot of load-bearing work in most people’s threat models.

Phishing works fine against TOTP. A convincing login page collects your password and your six digits and replays both to the real site within the window. The code is unforgeable but not bound to the site you thought you were on — the phone has no idea which domain you are looking at. This is TOTP’s one genuinely serious weakness, and it is the reason WebAuthn/passkeys exist: those sign a challenge that includes the origin, so a code harvested on social-secure-login.com is worthless against social.com.

The secret is a copyable file. Anything that can read the app’s storage — a malicious backup, a compromised cloud sync, a rooted device — clones the second factor silently and permanently. There is no “log out other sessions” for a stolen TOTP secret; you have to re-enroll.

Lose the device, lose the factor. No SMS to fall back on is the feature and the hazard. Recovery codes are not a nice-to-have, and neither is letting users enroll more than one authenticator.

The server-side secret store is a jackpot. Symmetric secrets stored in a database that must be able to read them, for every user. Envelope-encrypt them, keep them out of logs and backups you cannot control, and treat that table’s blast radius as equal to the password table’s.


Weighed against SMS, though, the comparison is not close. TOTP removes an entire delivery chain from the trust boundary, works with the radio off, costs nothing per authentication, cannot be SIM-swapped, and does not put your second factor in the same mailbox that can reset your first. It buys all of that with one exchanged secret and the observation that both parties already know what time it is.

The catch is that you can only pick TOTP where it is offered, and often it is not. Social platforms, email providers, and developer tools have supported authenticator apps for years; retail banking largely has not. Most Indian banks still authenticate with an SMS OTP and nothing else, which is precisely why a trip abroad turns net banking into a guessing game about whether your roaming works.

So: if you build authentication, offer TOTP, offer passkeys, and treat SMS as the accessibility fallback of last resort rather than the default. If you only use it, move the accounts that do support it off SMS this week, and keep the recovery codes somewhere that is not the phone.