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.
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):
- Take the current Unix time in seconds and divide by 30, rounding down. That is the time step.
- HMAC-SHA1 the step, as an 8-byte big-endian number, with the shared secret as the key.
- 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 -uagainst a reliable clock. Containers and VMs drift when NTP is off.timedatectlshows 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.