A one-time code email has one job: get a six-digit number in front of a person within seconds. If it takes two minutes, they leave. If the code is easy to guess, an attacker gets in. Both failures come from small decisions made in an afternoon.
Generate the code properly
Use a cryptographically secure random source, never Math.random(). In Node that's crypto.randomInt(0, 1_000_000) padded to six digits. In Python, secrets.randbelow(1_000_000).
Six digits means a million possibilities. That's fine as long as you limit guesses. With five attempts an attacker has about a one in 200,000 chance. The attempt limit does more work than the length.
Store a hash and make it expire
Treat the code like a short-lived password. Store a hash with the user ID, creation time and an attempt counter, so a database leak doesn't hand out live codes. A plain fast hash of six digits can be brute-forced offline in moments, so use an HMAC with a server-side secret. The real protection is the expiry.
Ten minutes is a common ceiling. Five is better for sign-in. Invalidate the code the moment it's used, and invalidate older codes when you issue a new one, so only one live code exists per person and purpose.
Limit attempts and requests
You need two separate limits.
- Verification attempts. After five wrong guesses, kill the code and make the person ask for a new one.
- Sending. Limit how often a code can be requested per account and per IP address, for example one a minute and a few per hour.
Without the second limit, your sign-up form turns into a free email cannon that anyone can aim at someone else's inbox. That hurts the victim, and it hurts your sender reputation.
Also respond identically whether the address has an account or not: "If that address has an account, we've sent a code." Otherwise you've built a tool for checking who uses your product.
Write the email for someone in a hurry
The person is staring at a form. Make the message readable in one glance.
Subject: Your sign-in code is 482913
Your code is 482913.
It expires in 10 minutes.
If you didn't ask for this, ignore this message.
Nobody can sign in without the code.
Putting the code in the subject means it shows in the phone notification, so they may not need to open the email. Keep the body free of banners, promotions and a pile of links. A verification message that looks like an advert looks like phishing. Send both a plain text and an HTML part.
Why codes arrive late
Delay is failure for this kind of email, so check the usual causes:
- A bulk queue. Send codes through a transactional path, not the same one as your newsletter.
- A cold domain. Receivers throttle senders they don't know yet.
- Missing authentication. Without SPF, DKIM and DMARC, mail is slowed, junked or refused. Start with what an SPF record is.
- Sending inside the page request. Call the API with a timeout and don't let the page hang.
Handle failure honestly
Sometimes the mail server can't take a message right away. On Mailions's API that returns 202 with status: queued, and the message is saved and sent when the server returns, for up to 24 hours. For most email that's a relief. For a login code it's useless, since a code that shows up an hour late has already expired.
So for codes, tell the person plainly that sending is delayed and offer a resend after a minute. Tie an Idempotency-Key to the code request so a retry after a timeout can't send two copies. The API guide documents the header, and Node.js sending shows it in code.
Design the resend button
People click resend within seconds of the first email not showing up. Plan for it.
- Disable the button for 30 to 60 seconds after each send and show a countdown, so nobody hammers it.
- Say where you sent the code ("We sent a code to a***@example.com") so a typo in the address is obvious.
- When a new code goes out, kill the old one. Two live codes confuse people, and they type the old one.
- After three or four resends, suggest checking the spam folder and offer another way in, such as a support link.
Test without flooding your own inbox
You'll send dozens of codes while building. Use a throwaway address instead of your own: Mailions's public temporary inboxes need no sign-up. Pick a name, send there, and watch the message arrive. Free inboxes delete each message 30 minutes after it arrives, and anyone who knows the address can read it, so use test data only.
Checklist
- Secure random code, six digits, stored as a hash
- Expires in 5 to 10 minutes and works once
- Five attempts, then a new code is required
- Requests limited per account and per IP
- Same response for known and unknown accounts
- Short plain email with text and HTML parts
- Authenticated domain and a dedicated sending address
- API timeout and a clear message when sending fails
Building this and want it sent from your own domain? The API guide has OTP examples for Node.js, PHP and Python, and we can set up a trial for you.