SYSTEM: ONLINE
THREAT FEED: LIVE
LAST SCAN: August 14, 2026
247plan_net

How the 6-Digit Code Is Generated

Quick answer: Your authenticator app holds a shared secret. Every 30 seconds it computes HMAC-SHA-1 of that secret and the current time step, takes the low four bits of the last byte as an offset, reads four bytes from that offset, masks off the top bit, and takes the remainder modulo one million. That is the whole algorithm. The phone never talks to the server, the code is a pure function of secret and clock, and it carries no information about who is asking for it, which is exactly why it cannot stop a phishing proxy.

A terminal window showing an HMAC digest being truncated into a six-digit authentication code

There is a specific kind of frustration in typing a six-digit number off a screen several times a day for years without knowing where it comes from. The number is not random. It is not stored anywhere. It is not sent to you. Your phone computes it locally, the server computes the same one independently, and the two agree because they share a secret and a clock.

That is the short answer to how authenticator apps work. The long answer is more interesting, and it explains exactly why this scheme cannot protect you from a competent phishing page.

This article works the whole thing by hand. Every number below came out of a scratch implementation written from the RFC text, and every one of them can be reproduced with any HMAC tool you already have.

The algorithm, in six lines

The specification is RFC 4226 for HOTP, published December 2005, and RFC 6238 for TOTP, published May 2011. Worth noting before we start: both are Informational RFCs, not Standards Track. The mechanism guarding a large fraction of the world's logins was never actually standardised.

RFC 4226 states the core in one line:

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

`K` is the shared secret. `C` is an 8-byte big-endian counter. TOTP changes exactly one thing: instead of a counter that increments on a button press, `C` is the number of 30-second intervals since the Unix epoch. That is the entire difference between the two algorithms. There is no separate TOTP math.

Here is a complete evaluation, using the RFC's own test secret and a timestamp from March 2005:

Secret K (ASCII)   = "12345678901234567890"
Secret (Base32)    = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ   <- what the QR carries
Unix time t        = 1111111111
T = floor(t / 30)  = 37037037 = 0x00000000023523ED
Counter (8-byte BE)= 00000000023523ed

HMAC-SHA-1(K, C)   = b0092b21d048af209da0a1ddd498ade8a79487ed
                                              ^^^^^^^^      ^^
last byte 0xed, low nibble = 0xd = 13         |             |
                                              +- bytes[13..16] = 98 ad e8 a7
as 32-bit integer  = 0x98ade8a7   (2561534119)
& 0x7fffffff       = 0x18ade8a7   (414050471)   <- top bit discarded
mod 10^6           = 050471                     <- your code
mod 10^8           = 14050471                   <- the RFC's 8-digit value

Two details in there are worth stopping on.

The `digits` setting is nothing but the modulus. Notice that `050471` is literally the last six digits of `14050471`. A six-digit code and an eight-digit code from the same secret at the same instant are the same number, cut at a different place.

Codes are strings, not integers. That leading zero is real, and roughly one code in ten has one. Treating the output as an integer and printing it is a classic implementation bug that produces a five-digit code once every ten tries.

The bit that gets thrown away for no good reason

Look at the mask again: `0x98ade8a7` becomes `0x18ade8a7`. The high bit is deleted before the modulo.

You might assume this is cryptographic hygiene. It is not. RFC 4226 says the reason plainly: it is there "to avoid confusion about signed vs. unsigned modulo computations."

That is a portability workaround. Java has no unsigned 32-bit integer type, so a four-byte slice with the top bit set would be read as a negative number, and `-1735633241 mod 1000000` does different things in different languages. Rather than specify the arithmetic, the authors deleted the bit.

So every authenticator code you have ever typed is derived from 31 bits of a 160-bit hash, and the 32nd bit was sacrificed to a language's type system in 2005. It costs nothing in security terms, since 31 bits is still vastly more entropy than six digits can express. It is simply a piece of 2005 engineering pragmatism fossilised into a billion devices.

The offset trick, and why the digest is exactly 20 bytes

The truncation step reads the low four bits of the final byte and uses that as a starting index. Four bits gives 0 through 15. Read four bytes starting at index 15 and you touch byte 18. HMAC-SHA-1 produces exactly 20 bytes, indices 0 to 19.

The window can never run off the end. That is not a coincidence, it is why the offset field is four bits wide. Change the hash to SHA-256 and the same four-bit offset now addresses only the first half of a 32-byte digest, which is harmless but slightly wasteful, and it is one reason the SHA-1 default has never been urgent to move away from. HMAC-SHA-1 is not broken as a message authentication code; SHA-1's collision weakness simply does not apply here.

The QR code is a text file

The enrollment QR is not a cryptographic artifact. It encodes a URI, in plain text:

otpauth://totp/Example:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example&algorithm=SHA1&digits=6&period=30

Point any QR scanner at an enrollment code and you will see that string, secret included. This has a practical consequence people underuse: you can screenshot the URI once and enroll several devices from it, and a much less pleasant one, which is that a screenshot of an enrollment QR sitting in a photo roll is a permanent, complete credential.

The `secret` is Base32, not Base64, per RFC 4648. Base32's alphabet is `A` to `Z` plus `2` to `7`, which is case-insensitive and has no visually ambiguous characters, because the format was designed on the assumption that a human might have to retype it off a screen.

Here is my favourite detail in the entire specification. The sample secret in Google's own documentation is `JBSWY3DPEHPK3PXP`. Base32-decode it:

JBSWY3DPEHPK3PXP  ->  b'Hello!\xde\xad\xbe\xef'

The bytes are ASCII `Hello!` followed by `DEADBEEF`. Somebody left a joke in the reference documentation for two-factor authentication and it has been copied into a thousand tutorials by people who never decoded it.

One more thing about that URI scheme: `otpauth://` is not a standard. It is not an RFC and it is not an IANA-registered URI scheme. The authoritative definition is a wiki page in the Google Authenticator GitHub repository. An IETF draft to formalise it reached version 02 in February 2025 and then expired. The interchange format for the world's two-factor authentication is a page on a wiki, and it works fine, which tells you something about how standards actually happen.

Clock drift, and the 89-second window

Your phone has no clock authority. It trusts its own time, computes a code, and the server has to decide whether to accept it. RFC 6238 recommends the server accept a small number of steps backward, and gives the exact arithmetic:

> If the time step is 30 seconds as recommended, and the validator is set to only accept two time steps backward, then the maximum elapsed time drift would be around 89 seconds, i.e., 29 seconds in the calculated time step and 60 seconds for two backward time steps.

Twenty-nine plus sixty. That asymmetric-looking number is where "why did my code stop working" almost always ends: the phone's clock drifted past the window. The fix is to turn on automatic network time, not to re-enroll.

The RFC is also explicit that a used code must be burned: the verifier "MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP." The 30-second display window is not a 30-second reuse window. On a correctly built server, a code that has been spent is dead the instant it is spent.

Why the counter version lost

HOTP's `C` is a counter that advances on a button press. This creates a failure mode with no equivalent in TOTP: every press that never reaches the server advances the client and not the server, permanently. The gap only ever widens.

The specification's answer is a look-ahead window, where the server computes the next few values and resynchronises if one matches. It also recommends throttling, because six digits is only a million possibilities and a wide look-ahead window multiplies an attacker's chance per guess. Those two parameters pull against each other, and the tuning problem is unpleasant.

Time solves this for free. A clock resynchronises itself; a counter does not. That is why the hardware token in your desk drawer that you pressed two hundred times out of boredom is now permanently useless, and why TOTP won.

The RFC's own test vectors have been wrong since 2011

This is the part I did not expect to find.

RFC 6238 Appendix B publishes a table of test vectors for SHA-1, SHA-256 and SHA-512, and states that the whole table uses the ASCII secret `12345678901234567890`. Implementers use this table to check their code.

It does not work. I implemented the algorithm from the spec and ran it:

Using the seed the RFC actually states, for all three columns:

  unix time     SHA-1      SHA-256    SHA-512
  ------------------------------------------------
  59            OK         32247374   69342147
  1111111109    OK         34756375   63049338
  1111111111    OK         74584430   54380122
  1234567890    OK         42829826   76671578
  2000000000    OK         78428693   56464532
  20000000000   OK         24142410   69481994

  matches: SHA-1 6/6,  SHA-256 0/6,  SHA-512 0/6

The SHA-1 column is perfect. The other two miss every single row.

The reason is Errata ID 2866, reported in July 2011 and marked Verified in November 2011. The seeds are different per algorithm, sized to the hash: 20 bytes for SHA-1, 32 for SHA-256, 64 for SHA-512. The erratum's own note is wonderfully dry: "The example Java code respects this, but the test vector documentation does not."

Substitute the correct seeds and everything lines up:

Using the per-algorithm seeds from Errata 2866:

  unix time     SHA-1      SHA-256    SHA-512
  ------------------------------------------------
  59            OK         OK         OK
  1111111109    OK         OK         OK
  1111111111    OK         OK         OK
  1234567890    OK         OK         OK
  2000000000    OK         OK         OK
  20000000000   OK         OK         OK

  matches: SHA-1 6/6,  SHA-256 6/6,  SHA-512 6/6

So the canonical test vectors for the algorithm behind a large share of the world's logins have been wrong in the published document for fifteen years. The correction has sat there, verified, the entire time, and the RFC has never been reissued. Every implementer who has tried to validate a SHA-256 TOTP against Appendix B has hit this and had to go find the erratum.

Nothing is broken. It is just a reminder that the specifications everything rests on are maintained by people, sporadically, and that "it's in the RFC" is not the same as "it's right."

What this design cannot do

Everything above should make one limitation obvious. The code is a pure function of the secret and the clock. It contains no information about who is asking for it.

If a convincing fake login page asks you for a code and relays it to the real site within the same 30-second step, the code works. It cannot not work. It has no way to encode the fact that you typed it into the wrong place.

NIST states this normatively. SP 800-63B-4, finalised in July 2025, defines phishing resistance as preventing disclosure to an impostor verifier "without relying on the vigilance of the claimant," and then rules:

> Authenticators that involve the manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant.

CISA is blunter, in guidance issued after the Salt Typhoon telecom intrusions: "While authenticator codes are better than SMS, they are still vulnerable to phishing. Only FIDO authentication is phishing-resistant."

This is not theoretical. Phishing-as-a-service kits sold on subscription now run a proxy between you and the real login page, relay the credentials and the code in real time, and steal the session cookie the server issues afterward. Changing your password later does not evict the attacker, because they never needed your password again. One documented kit was observed across more than a thousand domains and sold for a few hundred dollars for ten days of access.

The trend in the numbers matches. In the FBI's 2025 report, phishing and spoofing complaints were roughly flat year over year at about 191,000, while reported losses from them rose to about $216 million from roughly $70 million the year before. Fewer people are being caught, and each catch is worth far more. That is the signature of targeted session hijacking replacing bulk credential theft.

Two corrections to things you have probably read

SMS is not banned. NIST's current guidance places phone-based delivery in a category it calls restricted, not prohibited. A provider offering it must also offer an unrestricted alternative, warn users about the risks, and keep a migration plan. The word "deprecated" gets attached to this constantly and it is wrong. What is flatly prohibited is email: "Email SHALL NOT be used for out-of-band authentication."

Interestingly, the data has moved against the standard SIM-swap panic too. IC3 recorded 971 SIM-swap complaints in 2025 against 1,075 in 2023, with losses down roughly two thirds over that period. Carrier PINs and port freezes appear to have worked, and attackers moved to proxy phishing, which is cheaper and needs no insider.

Turning on an authenticator app does not turn off SMS. CISA calls this out specifically: enrolling in app-based codes usually leaves the SMS fallback enabled, and an account's real security is its weakest enrolled method, not its strongest. Go back into each account and remove the phone number as a login factor once the app is working.

Recovery codes are the actual back door

You can deploy a hardware key, disable SMS, and do everything correctly, and the account's true security floor is still the list of recovery codes you were given at setup. They are bearer tokens. No second factor, no expiry, and they are a documented bypass of every control layered above them.

NIST calls them look-up secrets and does regulate them: they must come from an approved random generator, be at least six digits, be single-use, and be stored hashed by the verifier. That last requirement produces a neat asymmetry worth understanding. A breached TOTP seed database hands an attacker a permanent code generator, because the server has to keep those seeds recoverable to compute codes. A breached recovery-code database, implemented properly, hands them nothing.

Store recovery codes offline and out of band from the vault that holds the password. Never in the same place as both the password and the seed, and never in email, which is the reset path for everything else you own.

Where to keep the seeds

The seed is a symmetric secret. The server has to store it in a recoverable form to check your codes, which is a real and permanent risk on their end. Your end you can control.

The honest trade on putting TOTP seeds in your password manager: it collapses two factors into one vault, so a vault compromise takes both. Against that, the threats TOTP actually defends most people from are remote ones, credential stuffing, breach dumps, reused passwords, and a manager with integrated codes defeats all of them while a separate app that you abandon because syncing hurts defeats none. It is a defensible trade for ordinary accounts and a bad one for the accounts that could rebuild the vault: your email, your domain registrar, and the password manager's own account. Proton's documentation says the self-referential case out loud, advising you never to protect your Proton account with TOTP stored in Proton Pass.

If you want the codes in the vault, NordPass stores TOTP alongside passwords and our full NordPass review covers where it beats and loses to Bitwarden and 1Password. If you want them separate, a dedicated app with an encrypted export is the safer architecture.

The real upgrade, when a site offers it, is a passkey. Origin binding means the browser refuses to sign for a lookalike domain, which removes the human vigilance that TOTP silently depends on. Keep TOTP for the many sites that still do not support anything better. Just understand that it is now the fallback, not the destination.

If you enjoy this sort of thing, the same appetite for reading a protocol rather than trusting it turns up in pulling signals out of the radio spectrum and in the craft of character art, where the constraint is a grid of glyphs instead of twenty bytes of hash.

Frequently Asked Questions

How does an authenticator app work without internet?

It does not need any. The app stores a secret you enrolled once, reads your device clock, and computes HMAC-SHA-1 of the secret and the current 30-second time step locally. The server independently computes the same value from the same secret and its own clock. Nothing is transmitted to generate a code, which is why airplane mode makes no difference.

Why does my authenticator code say invalid?

Almost always clock drift. The server typically accepts codes up to about 89 seconds old, so a phone whose clock has drifted further than that produces codes that are arithmetically correct and rejected anyway. Enable automatic network time on the device. The second most common cause is that the code was already used, since a correctly built server burns each code on first use.

Is TOTP better than SMS for two-factor?

Yes, meaningfully. SMS can be intercepted at the carrier and is exposed to SIM swapping, while a TOTP secret never traverses the network after enrollment. Neither is phishing-resistant, though: NIST excludes any manually entered code from that category. If a site offers passkeys or a hardware security key, that is the real upgrade.

Can I use the same QR code on two phones?

Yes. The QR encodes a plain-text `otpauth://` URI with the secret in it, and any number of devices holding that secret generate identical codes. Scanning the same code on a second device is a legitimate backup strategy. It also means a screenshot of that QR is a complete, permanent credential, so treat it accordingly.