29 Nov 2025
I had to re-examine our use of email OTPs for app authentication recently and make sense of the concepts of nonces and PKCE (Proof Key for Code Exchange).
To preface, OTPs are not great for security. Others have pointed out that it's not too difficult to phish users if you can convince them to log on to your website. Less observant users will make the mistake of sharing their OTPs with the wrong website.
A nonce does not solve this. A nonce is a "number used once" that is random and single-use. It is intended to prevent replay attacks.
(1) If the OTP email is somehow intercepted by an attacker, the attacker can proceed with the OTP submission step simply by providing the OTP token with the user's ID, thus obtaining the user's session by OTP interception.
While this seems unlikely, consider that intercepting an OTP email could be as simple as shoulder surfing.
(2) Another problem is that DoS (Denial of Service) attacks are possible.
An attacker can repeatedly attempt the OTP verification step to immediately invalidate any legitimate login attempts by the user. This effectively blocks the user from accessing the app.
With a nonce, an attacker who intercepts the OTP (e.g. through shoulder surfing) cannot simply use it to verify themselves from a different session. They would need both the OTP and the nonce, which was returned only to the original session that initiated the login.
This addresses Problem #1 (OTP interception).
Crucially, in step 4, if the nonce is invalid, it should not increment the number of attempts.
This addresses Problem #2 (DoS attacks) since attempts with invalid nonces would not interfere with legitimate login attempts.
There is a minor issue in that the nonce is sent back to client in the response payload. In the case that the payload is somehow intercepted, the protections we get from nonces are nullified. We will refer to this as Problem #3 (Network interception). While decidedly unlikely, it would be nice if we could address this without a high implementation cost.
This is where PKCE (Proof Key for Code Exchange) comes in. PKCE is a technique from the OAuth 2.0 specification (RFC 7636) that we can adapt for OTP flows.
It is not dissimilar from the nonce-based approach, but the key insight with PKCE is that instead of the server giving you a nonce, you prove you're the same session by solving a cryptographic challenge that you set up.
Here's how it works:
codeVerifier (a cryptographically random string, or a UUID) and a codeChallenge (a SHA256-hash of the codeVerifier). The codeChallenge is sent to the server when the email is submitted.codeChallenge, generates an OTP, and emails it to the user. The codeChallenge is stored server-side.codeVerifier UUID in the request.codeVerifier and compares it against the stored codeChallenge. If they match AND the OTP is correct, the user is logged in.An implementation detail: It's important that the login attempt is represented as a function of both the email and the codeChallenge, (e.g. store it as a key like [email]:[codeChallenge]).
This uniquely identifies the login attempt, and correspondingly, login attempts with a mismatched codeChallenge must not increment the attempt counter.
This elegantly solves all our problems:
Problem #1 (OTP interception): An attacker who intercepts the OTP cannot use it from a different session, because they don't have the original codeVerifier — that value only exists in the browser memory of the session that initiated the login.
Problem #2 (DoS attacks): Each login attempt has a unique identifier. An attacker making repeated requests with different code challenges is creating entirely separate login attempts that don't interfere with the legitimate user's attempt. The legitimate user's verification token remains untouched.
Problem #3 (Network interception): With the nonce approach, the server transmits the nonce to the client in its response, meaning an attacker monitoring server responses could obtain it and use it alongside an intercepted OTP. With PKCE, the codeVerifier is never sent by the server — it is generated entirely client-side and only ever travels client-to-server. An attacker who can only observe server responses cannot obtain the codeVerifier.
(Note: if an attacker could intercept and decrypt the OTP submission request in step 3 and front-run it, the codeVerifier would be exposed — but this is a significantly harder and more active attack than passively reading a server response.)
Overall, the fundamental difference from nonces is that PKCE provides cryptographic binding to the session.
The server never sends you the secret (codeVerifier) — you generate it and prove you have it by submitting it later.
An attacker cannot guess it or obtain it from server responses, and cannot interfere with your login attempt without a codeVerifier that is very difficult to obtain.
This was a fun exercise. We have since implemented this across a number of our products.
As with many cryptographic protocols, it feels obvious in hindsight and is conceptually elegant, but it was not an obvious approach to take when initially designing an OTP flow.