# Scan This QR and I'll Predict Your Next 2FA Code

Your authenticator app works in airplane mode, yet the server always knows your code. Scan a QR on this page, and watch it predict your next code before your phone shows it. Then we take TOTP apart, step by step.

Published: 25th October 2026

Turn on airplane mode and open Google Authenticator. The codes keep changing every 30 seconds. Type one into a website and it works.

Your phone didn't send anything. The server didn't send anything. So how does the server know which six digits are on your screen?

Let's skip the explanation for a minute. Scan the QR below with any authenticator app. Then press the button, and this page will tell you the code that's on your phone **and the next one before it shows up**.


Did it get it right? It should have. And it wasn't a trick. Here's what happened.

## Two parties, one secret, one clock

That QR code is just text. If you decode it, it looks like this:

```txt
otpauth://totp/Faisal%20Husain:blog-visitor?secret=JBSWY3DPEHPK3PXP...&issuer=Faisal%20Husain&algorithm=SHA1&digits=6&period=30
```

The important part is `secret`. It's a random 20-byte key, written in [Base32](https://datatracker.ietf.org/doc/html/rfc4648#section-6) so a human can type it if the camera fails.

When you scanned it, your app saved that secret. This page also has it (in a real app, the server would save it in your user record). From that moment on, nothing ever needs to travel between the two again.

Each side then runs the same calculation:

```txt
code = f(secret, current time)
```

Same secret. Same time. Same function. Same answer. That's the whole trick. My "prediction" was just me running `f` with a time 30 seconds in the future.

This is called **TOTP**: Time-based One-Time Password, defined in [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238).

A small correction to something I believed for a long time: the server doesn't store a *hash* that it compares against. It stores the **secret itself**, because it needs it to compute the expected code. That's also why a leaked 2FA secret is as bad as a leaked password: anyone with it can generate your codes forever.

## From the clock to six digits

So what is `f`? Fewer steps than you'd think. The lab below runs it live with the secret you just scanned, so the last step should match your app.


Here's the same thing in code. It's the actual function this page uses:

```ts
async function totp(secret: Uint8Array, now = Date.now()) {
  // 1–2. Turn the time into a counter that changes every 30 seconds
  const counter = Math.floor(now / 1000 / 30)

  // 3. Counter as 8 bytes, big-endian
  const message = new Uint8Array(8)
  let c = counter
  for (let i = 7; i >= 0; i--) {
    message[i] = c & 0xff
    c = Math.floor(c / 256)
  }

  // 4. HMAC-SHA1 with the shared secret as the key
  const key = await crypto.subtle.importKey(
    'raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
  )
  const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, message))

  // 5. Dynamic truncation: the last nibble picks where to read 4 bytes
  const offset = hmac[19] & 0x0f

  // 6. Read them as a 31-bit number (top bit dropped)
  const n =
    ((hmac[offset] & 0x7f) << 24) |
    (hmac[offset + 1] << 16) |
    (hmac[offset + 2] << 8) |
    hmac[offset + 3]

  // 7. Keep the last 6 digits
  return String(n % 1_000_000).padStart(6, '0')
}
```

A few things that clicked for me while writing this:

- **The time is the only input that changes.** Dividing by 30 and flooring it means every second in a 30-second window produces the same counter, so the same code.
- **HMAC is what makes it safe.** Without the secret, the output looks random. Seeing a hundred of your old codes doesn't help anyone guess the next one.
- **Steps 5–7 just turn 20 bytes into something a human can type.** Dropping the top bit avoids signed-integer differences between languages.

If you swap the counter for a number that goes up every time you press a button, you get **HOTP** ([RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226)), the older sibling. TOTP is literally HOTP with the clock as the counter.

## What if the clocks disagree?

This all depends on your phone and the server agreeing on the time. They usually do, because both sync over the network. But two things get in the way:

1. **Your phone's clock can drift**, especially if it's been offline for a long time.
2. **You take time to type.** You read the code at second 24, fumble the keyboard, and hit submit at second 32. The server is already in the next window.

So servers don't check just one window. They check the current one and its neighbours, usually one step back and one step forward. Play with it:


That ±1 window is a trade-off. Bigger means fewer "invalid code" complaints, but it also means each code stays valid for longer. ±1 gives you about 90 seconds in total, which is the common default.

Some servers go one step further and remember which window you were last accepted in. If your phone is consistently 40 seconds behind, they adjust for it next time.

## The server side, in a few lines

When you put it together, verifying a code on the backend looks roughly like this:

```ts
async function verify(user: User, submitted: string) {
  const counter = Math.floor(Date.now() / 1000 / 30)

  for (const drift of [0, -1, 1]) {
    const c = counter + drift
    if (c <= user.lastUsedCounter) continue // already used: block replays

    if (timingSafeEqual(await hotp(user.totpSecret, c), submitted)) {
      user.lastUsedCounter = c
      return true
    }
  }
  return false
}
```

Two details that are easy to miss:

- **Replay protection.** A code is valid for up to 90 seconds. If someone watches you type it, they could reuse it. Storing the last accepted counter makes each code single-use.
- **Constant-time comparison.** Comparing strings with `===` can stop at the first different character, which leaks timing information. Use something like Node's `crypto.timingSafeEqual`.

And when you first set up 2FA for a user, you generate the secret, show it as a QR, and **ask for one valid code before turning it on**. That confirms they actually scanned it, and that their clock is sane.

## So, offline?

Yes, completely. The only thing that ever crossed the gap was the QR code you scanned at the start. Since then, your phone and this page have been doing the same maths, on their own, on opposite sides of an airplane-mode switch.

That's why I could "predict" your code. I didn't need your phone. I just needed the secret and a clock.

Don't forget to delete the **Faisal Husain** entry from your authenticator app. It was a throwaway secret, but it's a good habit.

-- bye for now

Original page: https://faisalhusa.in/blog/how-authenticator-apps-work-offline
