Why a Valid One-Time Code Still Fails at VPN Login
A rejected code is usually a prompt-order, enrolment or timing problem, not a wrong code. Check these in order before you re-enrol anything.
You open the VPN client, type the password, and the six digits from your authenticator come back as invalid — twice, then a third time. Nothing changed on the account since yesterday, so the instinct is to delete the authenticator entry and scan it again — usually the wrong repair. A one-time code is not a second password: the gateway has to ask for it in a specific step, from a specific enrolled entry, inside a specific window, and most rejections happen before the digits are compared. Work out which step you are in before you touch the enrolment.
The prompt order tells you which flow you are in
Three shapes cover almost every VPN login that involves a code. In the two-step shape the client sends the password, the server issues a challenge, and the client prompts again. In the combined shape the gateway wants password and code in one submission — sometimes concatenated, sometimes after a separator only the operator documents. In the browser shape the client has no field at all: the code goes into a web page, or a push approval replaces it, and the desktop client waits for the result.
Getting the shape wrong looks like a bad code. Paste a fresh code into a password-only field and the server reads it as part of the password. On the client side, OpenVPN 2.4 and later make the difference visible: a server answering with AUTH_FAILED,CRV1: expects a challenge response, so the profile needs static-challenge or a password file in SCRV1: form. An older profile carrying only auth-user-pass authenticates the password and then stalls, which reads like an OTP failure in the log even though no code was sent.
WireGuard is worth naming as the exception: it authenticates key pairs rather than people, so there is no field for a code. If a provider advertises one, something outside the tunnel accepts it.
What the server does with the six digits
Time-based codes follow RFC 6238, which wraps the older counter-based construction from RFC 4226: an HMAC over a moving factor, truncated to six digits by default. For TOTP the moving factor is the current Unix time divided by a step, 30 seconds unless the operator changed it. The verifier never compares your digits to a stored value; it recomputes the expected code from the shared seed and normally accepts the neighbouring steps too, so a slow round trip does not fail the login.
Two consequences get missed. The phone's time zone is irrelevant, because the seed never sees a local clock — only the offset from real UTC matters, and a handset running two minutes fast fails every code while showing a clean countdown. And a code is single-use by design: the verifier remembers the last counter value it accepted for that seed, so the same digits submitted again are rejected even inside the same window. A client that retries after a timeout can burn the code it just sent.
Counter-based tokens move their counter on every press, so they drift out of sync after codes are generated but never submitted; the usual repair is a resynchronisation window that accepts a few values ahead. If the operator enrolled HOTP and your app displays TOTP, every value is wrong in a way that looks like a clock problem. The provisioning URI behind the QR code names the type, digits, period and hash.
Failures that look like a wrong code
The enrolled entry is the most common culprit. Authenticator apps label entries by issuer and account, so anyone holding two identities on the same VPN can end up with two entries whose labels differ by a character. A code is valid for the seed that generated it and for nothing else. Compare the label on the phone against the username the server records, not against what you remember typing.
Second is a mismatched parameter set. A seed enrolled with eight digits, a 60-second period or SHA-256 yields codes that a verifier expecting six digits, 30 seconds and SHA-1 refuses, and at the client that is indistinguishable from a wrong code. It appears after migrations where the entry was re-added from a photo of the QR code, or where the token record was rebuilt with defaults.
Third is not about the code. Gateways usually return one generic authentication failure for a bad password, an expired code and a disabled account, deliberately, so nobody can probe which factor failed. Read the gateway or RADIUS log instead of the client banner: a challenge never issued, an access-reject with no OTP attribute and a policy denial all render identically on screen and lead to different next steps.
A short sequence that narrows it down
Start with the cheapest check and stop at the first step that does not hold.
- Wait for a fresh code, submit it once, and note whether the challenge appears before or after the password is accepted.
- Match the label in the authenticator against the username the server stores.
- Confirm the profile can answer a challenge at all, as in the
CRV1case above. - Compare the device clock against a known time source; an offset beyond a few tens of seconds breaks TOTP on its own.
- Ask the operator whether the token is TOTP or HOTP, and which digits, period and hash were enrolled.
Two rules keep this from making things worse. Do not retry many times: plenty of gateways count failures per account and either lock it or start delaying responses, so a burst turns a small misconfiguration into a support ticket. And do not re-enrol the token as a diagnostic, because that invalidates the old seed; if the real fault was a challenge the client never sent, you now have two changes to reason about instead of one.
Keep the seed and the recovery codes out of the loop
The six digits and the secret behind them are not interchangeable. A provisioning QR code or a Base32 seed generates every future code for the account, so it belongs in the same category as the password: not in a screenshot, not in a ticket comment, not in a chat with someone who contacted you first. Recovery codes are a separate mechanism — single-use, often skipping the code prompt entirely, and spending one to test the account removes a fallback you may need later.
A code also does not make a login page trustworthy: an OTP prompt on a lookalike domain collects a valid code and replays it inside the window, and the certificate is still what tells you where you are typing.
If the sequence lands on a profile that cannot answer a challenge, fix the profile and retest with one fresh code. If it lands on mismatched enrolment parameters, have the operator read the token record before anyone scans anything. If it lands on a lockout, wait it out rather than switching clients — the account state is the blocker, not the app.