The Anatomy of a One-Time Code: How Email OTP Really Works (and Why It Sometimes Doesn’t)
That six-digit code in your inbox is a tiny, time-boxed secret with a surprising amount of engineering behind it. Here is how email one-time passwords are generated, delivered and verified — why they expire, why they sometimes never arrive, and how to build them well.
EvilMail Team6 Julai 202610 min bacaan
You have typed one in the last week, almost certainly. A six-digit number lands in your inbox, you copy it into a box, and a door opens — a login, a payment, a new account. The one-time code is the most quietly ubiquitous piece of security in your daily life, and precisely because it works, almost nobody stops to ask what is actually happening in the ten seconds between "send code" and "verified."
It turns out there is a surprising amount of engineering packed into that little number. It is generated a particular way, stored a particular way, given a deliberately short life, and checked with more care than you might expect. Understanding how it works explains a lot of everyday mysteries — why the code expires so fast, why hitting "resend" three times can lock you out, and why it sometimes never shows up at all. This is the anatomy of a one-time code.
What a one-time code actually is
A one-time password (OTP) is exactly what the name says: a secret that is valid for a single use and a short window of time, then it is worthless. That disposability is the entire point. A normal password is a long-lived secret — steal it once and you can use it for months. An OTP steals badly: by the time an attacker intercepts it, it has often already expired or been used.
There are two broad families. Delivered OTPs are generated by a server and sent to you over a channel you control — email or SMS. Generated OTPs (the codes in an authenticator app) are computed on your own device from a shared secret and the current time, with no delivery step at all. This article is about the first kind — the email code — because it is the one temp-mail users interact with constantly, and the one with the most moving parts between "generated" and "typed."
The journey of a code, end to end
Here is the whole lifecycle in one picture. Follow the numbered arrows: a request goes out, a code is born, an email travels, and a verification comes back.
A hand-drawn sequence diagram showing the journey of a one-time code between four actors — You, the app, the mail server, and your inbox. The app generates a six-digit code, stores only its hash with a ten-minute expiry, emails it, the mail server routes and delivers it, you read and submit it, and the app compares the hash and checks it has not expired before returning verified.
Let us walk each step, because every one exists for a reason.
1. You request a code
You click "send me a code." The request hits the application, which now has a decision to make and a few things to do — fast, because you are watching a spinner.
2. The app generates, hashes, and time-stamps
The server generates a random code — typically six digits — using a cryptographically secure random source, not a lazy rand(). Then comes the part most people never see: it does not store the code. It stores a hash of the code, alongside a creation timestamp and an expiry (often 5 to 10 minutes). This is the same principle as password storage: if the database leaks, the attacker gets hashes, not live codes. It also records how many attempts have been made, so it can enforce a limit.
3–4. The email travels and is delivered
The app hands the message to its mail infrastructure, which routes it across the internet — DNS lookups, an MX record, an SMTP handshake — and deposits it in your inbox. This is usually a matter of seconds, but "usually" is doing a lot of work in that sentence, and it is where most failures happen. More on that below.
5–7. You submit, the app verifies
You read the code and type it back. The server hashes what you submitted and compares it — ideally with a constant-time comparison, so an attacker cannot learn the code digit by digit from tiny timing differences. It checks three things, all of which must pass: the hash matches, the code has not expired, and you have not exceeded the attempt limit. Only then does the door open — and the code is immediately marked used, so it can never open a second door.
Why a code is time-boxed and single-use
The short lifespan is not an annoyance the designers forgot to remove; it is the security model. A one-time code is deliberately fragile so that a stolen one is almost always a dead one.
A hand-drawn timeline titled Anatomy of a secure code, showing a green valid window from generated to expires at about ten minutes, after which the code is dead. Sticky notes explain that the code is single-use, that six digits give roughly a one-in-a-million guess chance, that it is stored hashed and never in plain text, and that too many tries triggers a lockout because rate limits beat brute force.
Four properties do the heavy lifting:
Short expiry. A ten-minute window means an intercepted code is usually useless by the time it is abused. It also caps how long an attacker has to brute-force it.
Single use. Once verified, the code is burned. Replaying it does nothing.
Enough entropy, backed by rate limits. Six digits is only a million possibilities — trivial to brute-force if you had unlimited guesses. You do not. After a handful of wrong attempts the code is invalidated and the account is throttled. Entropy and rate limiting are a team; neither is sufficient alone.
Hashed at rest. The server can verify a code without ever being able to read it back. A database breach yields hashes, not a list of live codes.
Put together, these turn a humble six-digit number into something genuinely hard to abuse — not because the number is clever, but because the rules around it are.
Why your code sometimes doesn't arrive
Now the practical mystery. You asked for a code, and… nothing. Before you rage-click "resend," it helps to know where codes actually get stuck. Almost every failure is one of a small handful of causes.
A hand-drawn troubleshooting sketch titled Why didn't my code arrive, branching from the question No code after a minute into six checks: checked spam or junk folder (the number-one cause, because filters distrust automated mail), right address with no typo, waited about sixty seconds because delivery queues lag, hit resend too many times which rate-limits and can invalidate the previous code, code already expired within its ten-minute window, and sender blocked by the provider — with a suggestion to try another inbox or domain.
Reading the sketch clockwise:
Spam and junk — the number one cause. Automated, templated, link-bearing mail is exactly what spam filters are trained to distrust. Check the junk folder before anything else.
A typo in the address. One wrong character and the code is cheerfully delivered to someone else, or nowhere.
Latency. Delivery is usually seconds, but mail queues, greylisting, and provider throttling can add real delay. Sixty seconds of patience solves a lot.
Resend abuse — the counter-intuitive one. Each "resend" often invalidates the previous code and pushes you toward a rate limit. If you request five codes in a row and then type the first one, it will fail — you are holding an expired ticket. Request once, wait, and use the latest.
Expiry. If you walked away for fifteen minutes, the code is simply dead. Request a fresh one.
Sender reputation. Occasionally a mailbox provider blocks the sender's domain outright. If a service can never reach you, a different inbox or domain sometimes gets through.
The security pitfalls worth knowing
A one-time code is strong, but it is not magic, and a few failure modes are worth internalizing:
Nobody legitimate will ever ask you to read a code to them. A code is a proof you submit into a form. If a "support agent" on the phone asks you to tell them the code you just received, you are being phished in real time. Hang up.
The channel is the weak point. The code is only as secure as the inbox it lands in. If your email account is compromised, email OTP is compromised with it. This is the honest limitation of any delivered code.
Email beats SMS in one important way. SMS codes are vulnerable to SIM-swap attacks, where an attacker ports your number. Email OTP moves the trust to your mailbox instead — which you can protect with a strong password and its own second factor.
A code is not a password. It proves you can receive mail at an address right now. That is useful, but it is a much weaker claim than proving you know a secret. Treat "verified by email" as exactly what it is.
Building email OTP well: a short checklist for developers
If you are the one implementing this, the difference between a safe flow and a leaky one comes down to a handful of decisions. In pseudocode, the shape of a correct verify step:
on verify(user, submitted_code):
record = store.get(user.otp_id)
if not record: return fail("no active code")
if record.attempts >= MAX_ATTEMPTS: return fail("locked")
record.attempts += 1
if now() > record.expires_at:
return fail("expired")
if not constant_time_equal(hash(submitted_code), record.hash):
return fail("wrong code")
store.delete(record) # single use: burn it now
return success()
The non-negotiables behind that snippet:
Generate codes from a cryptographically secure RNG, never a general-purpose one.
Store a hash, never the code itself, with an explicit short expiry.
Compare in constant time to avoid timing side channels.
Enforce a per-code attempt limit and a per-account request limit.
Invalidate on success immediately, and invalidate old codes when a new one is issued.
Make the failure messages the same for "wrong code" and "expired" where you can, so you leak less to an attacker probing the endpoint.
Where disposable email fits in
People receive one-time codes on temporary inboxes constantly — signing up to try a product, grabbing a one-off download, keeping a low-stakes account off their primary address. For that, it is a perfectly reasonable tool: the code arrives, you use it, the inbox evaporates, and your real address never enters another marketing database.
The judgment call is stakes. For a throwaway account you will never log into again, a disposable inbox is ideal. For anything you actually care about — anything holding money, identity, or data you cannot lose — the address that receives your codes is part of your security, and it belongs on an inbox you fully control and can recover. Match the permanence of the inbox to the permanence of what it protects.
Takeaways
A one-time code is a deliberately fragile secret: single-use, short-lived, hashed at rest, and rate-limited. That fragility is the security, not a flaw.
Most "my code didn't arrive" problems are spam folders, typos, normal latency, or resend abuse that invalidated the earlier code. Request once, wait, use the latest.
No legitimate service will ask you to read a code aloud. A code goes into a form, never to a person.
Email OTP is only as strong as the mailbox it lands in — protect that inbox, and match its permanence to what it guards.
If you build it: secure RNG, hash + expiry, constant-time compare, strict rate limits, single use. The whole design is in those five words.
The next time a six-digit number slides into your inbox, you will know the small, careful machine that put it there — and why it will be gone in ten minutes.