Skip to content

Why your server rejects correct authenticator codes

Authenticator app codes rejected by your server: clock drift, base32 secrets, leading zeros, ignored parameters and reused codes, with a TOTP check in Node.

AuthenticatorBy Rushi Patel, Senior iOS developerUpdated 2 November 20267 min read

A user scans your QR code, types the six digits, and your server says the code is wrong. The code is almost never wrong. One of a handful of mismatches between your server and the authenticator app is. Here they are in the order to check them, then a short reference implementation.

How a TOTP code is made

Both sides compute the same thing (RFC 6238):

  1. Take the current Unix time in seconds and divide by 30, rounding down. That is the time step.
  2. HMAC-SHA1 the step, as an 8-byte big-endian number, with the shared secret as the key.
  3. Take 4 bytes from the HMAC at an offset given by its last byte, drop the top bit, and keep the last 6 decimal digits.

Anything that changes one input on one side changes the code.

1. The clock

A step is 30 seconds. If the server’s clock and the phone’s differ by more than that, the codes differ.

  • Check the server: date -u against a reliable clock. Containers and VMs drift when NTP is off. timedatectl shows whether it is synchronised.
  • Accept one step either side of now. That covers a phone a little off and a user who reads the code at second 29 and types it at second 33.
  • Phones set to manual time are the other cause. Google Authenticator on Android had a “Time correction for codes” option in older versions; today the fix is turning on automatic date and time.

2. The secret encoding

The secret in the otpauth:// URI is base32 (A to Z and 2 to 7). Your server must decode it to bytes before the HMAC. A common bug hashes the base32 string’s own characters as the key, which yields codes no app will ever match.

otpauth://totp/Example:asha@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example

Base32 is also case-insensitive and some apps show it in groups of four with spaces. Upper-case it and strip spaces and = before decoding when users type it by hand.

3. Leading zeros

012345 is a valid code. Stored or compared as an integer it becomes 12345 and never matches. Keep codes as strings and pad the computed one to six digits.

4. Parameters the app ignores

The URI can carry algorithm=SHA256, digits=8 and period=60. Google Authenticator and several other apps ignore them and always make 6-digit SHA-1 codes every 30 seconds. If your server honours the parameters and the app does not, every code fails. Keep the defaults.

5. A code that already worked

If the user signs in, signs out and types the same code again within 30 seconds, a correct server rejects it: RFC 6238 says a code must not be accepted twice. Store the last step that matched and refuse that step and every earlier one. Tell the user to wait for the next code rather than calling it invalid.

6. Enrolment never confirmed

If you save the secret when the QR code is shown, and the user scans a different QR code later (or scans nothing), the app and the server hold different secrets. Save the secret as pending, ask for one code to confirm, and only then switch two-step on.

A reference check in Node

import crypto from "node:crypto";

const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32Decode(s) {
  let bits = 0, value = 0; const out = [];
  for (const ch of s.toUpperCase().replace(/[\s=]/g, "")) {
    value = (value << 5) | B32.indexOf(ch); bits += 5;
    if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
  }
  return Buffer.from(out);
}

function codeAt(secret, step) {
  const msg = Buffer.alloc(8);
  msg.writeBigUInt64BE(BigInt(step));
  const h = crypto.createHmac("sha1", secret).update(msg).digest();
  const o = h[h.length - 1] & 15;
  const n = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
  return String(n % 1_000_000).padStart(6, "0");
}

// Returns the matched step, or null. Store it and pass it as lastStep next time.
export function check(secretB32, code, lastStep = -1, now = Date.now()) {
  const secret = base32Decode(secretB32);
  const step = Math.floor(now / 1000 / 30);
  for (const s of [step - 1, step, step + 1]) {
    if (s <= lastStep) continue;
    if (crypto.timingSafeEqual(Buffer.from(codeAt(secret, s)), Buffer.from(String(code).padStart(6, "0")))) return s;
  }
  return null;
}

Test it against the RFC’s own vectors, or against your phone: put the URI in a QR code, scan it, and compare.

Also needed in production

  • Rate limiting per user. Six digits are a million guesses; without a lock, a bot gets through. Lock after a few failures and grow the lock each time.
  • Backup codes, shown once, stored hashed, each usable once, for a lost phone.
  • The secret encrypted at rest. It has to be read back to check codes, so it cannot be hashed like a password.

With SignOps

SignOps Authenticator does all of the above on its servers: base32 secrets and the QR code, one step either side of now, each code accepted once, ten backup codes, a per-user lock after five wrong tries, and secrets encrypted at rest. Your server makes two calls, one to enrol and one to check. The authenticator setup guide builds the settings page and the sign-in step.

Sources: IETF RFC 6238: TOTP, IETF RFC 4226: HOTP, Google Authenticator wiki: Key URI format.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Why does my server reject a correct Google Authenticator code?

Most often the server's clock or the phone's clock is off by more than one 30-second step, the server only accepts the current step, the secret was decoded as plain text instead of base32, or the code was compared as a number and lost a leading zero. Check the server clock first, accept one step either side, and decode the secret as base32.

How many time steps should a TOTP server accept?

RFC 6238 recommends allowing at most one time step of network delay. Most servers accept the previous, current and next 30-second step, which covers a phone clock up to about 30 seconds off and a user who types slowly. A wider window makes guessing easier.

Can the same TOTP code be used twice?

It must not be. RFC 6238 says the verifier must not accept a second attempt with the same code after a successful check. Store the last time step that succeeded for each user and reject any code from that step or earlier.

Do authenticator apps support SHA-256 or 8-digit codes?

Not reliably. Google Authenticator and several others ignore the algorithm, digits and period parameters in the otpauth URI and always make 6-digit SHA-1 codes every 30 seconds. Use those defaults unless you control the app.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.