Short Answer
This is the difference between a resilient email OTP flow and a rolling bucket of invalid codes. Users do not experience email as an ordered queue. They switch tabs, request another code, search their inbox, open an older message, and type the first visible six digits. Your backend has to make that behavior safe.
The Superseded-Code Race
The common implementation treats every resend as a brand-new challenge. That looks simple in code, but it creates a race between inbox delivery, user attention, and your database write.
From the user perspective, the product sent them a code and then rejected it. From the system perspective, the code is invalid because a later resend replaced it. Both are true, which is exactly why the state machine is wrong.
The Fix: Idempotent Send
Resend should be a read-or-create operation, not an unconditional create. Look up the active challenge for the same tenant, normalized email, and purpose. If it exists and has not expired, reuse it. If it does not exist, create a new one.
sendOtp(email, purpose):
key = tenant_id + normalize(email) + purpose
active = find_active_challenge(key, now)
if active exists:
enqueue_email(active.code)
return active.id, active.expires_at, reused: true
challenge = create_challenge(key, ttl: 10 minutes)
enqueue_email(challenge.code)
return challenge.id, challenge.expires_at, reused: falseThis makes resend safe without hiding the resend button or asking the user to wait for a particular email. Every message in the active window contains the same answer.
The State Machine That Holds Up
A correct OTP flow is easier to reason about when it has explicit states. The important rule is that resend does not move an active challenge back to a new code.
No active challenge
Send creates a challenge with a code, purpose, attempt budget, and expiration timestamp.
Active challenge
Resend reuses the same code. Verify can succeed, fail an attempt, or expire the challenge.
Verified
The code cannot be used again. The product should issue its session, grant access, or complete the action.
Expired or locked
A later send can create a fresh challenge after the TTL ends or the attempt budget is exhausted.
Key By Email And Purpose
The idempotency key should include the normalized email address and the purpose of the code. In a multi-tenant system, include tenant or project context too.
Purpose isolation matters. A signup verification code should not satisfy an account deletion prompt. A report-access code should not log someone into a dashboard. Each purpose gets its own active challenge and its own attempt budget.
Use A Boring TTL
A 10-minute OTP TTL is a practical default for email. It gives real inboxes enough time to receive the message, lets users switch apps or devices, and still keeps the challenge short-lived. The key is that the 10-minute window is stable: resend extends delivery, not the answer.
If you do extend expiration on resend, do it deliberately and cap the total lifetime. Do not let an attacker keep a challenge alive forever by repeatedly pressing resend.
Implementation Checklist
- Normalize the email address before building the idempotency key.
- Include purpose and tenant context in the key.
- Use an atomic read-or-create operation so parallel sends do not create two active codes.
- Return the same challenge ID and expiration while the challenge is active.
- Keep a max-attempt budget on verify, not just a time-based expiration.
- Rate-limit sends by email, purpose, IP, and tenant to reduce abuse.
- Mark successful challenges as verified so the same code cannot be replayed.
Product invariant
Same email + same purpose = same active code.
That is the behavior sendotp.email is built around: stable 10-minute email OTPs for login, signup verification, access gates, and high-risk action confirmation.
FAQ
Why does my OTP code become invalid after resend?
Most often, resend creates a new OTP and invalidates the previous one while the old email is still in transit or already open in the user's inbox. The user enters the code they can see, but the backend now expects the newer code.
Should resend generate a new OTP?
For most email OTP flows, no. During the active TTL, resend should return and re-email the existing challenge for the same normalized email and purpose. Generate a new code after expiry or after the verified state ends the challenge.
What should an idempotent OTP be keyed on?
Use tenant context, normalized email, and purpose. That keeps login, signup verification, report access, and high-risk actions isolated while making retries safe inside each flow.
Can resend still send another email if the code is the same?
Yes. Idempotency does not mean suppressing delivery. It means the active challenge stays the same, so another email can be sent without changing the answer the user must type.