Email OTP testing · state laboratory
Which Code Wins? A QA Lab for Testing Email OTPs, Resends, Expiry, and Replay
Test email OTP verification as a code lifecycle: identify the current request, match the right message, verify resend policy, expiry, wrong attempts, rate limits, successful use, and replay without accepting a stale code by accident.
An OTP test becomes interesting the moment there is more than one plausible code. A user requests a code, waits, presses resend, receives two messages out of order, mistypes once, and finally submits something that may already be stale. The inbox only shows artifacts of that history. QA has to reconstruct the state behind them: which request created each code, which code the verifier should accept now, when validity ends, what failed attempts change, and what remains possible after success.
The question that keeps the whole test honest
At every point in the scenario, be able to answer: which OTP should the product accept right now, for which identity and action, and why? If the test cannot answer that before submitting a code, a green result may only prove that some code happened to work.
Put every OTP into a lifecycle state
| State | What happened | What QA should know |
|---|---|---|
| Requested | The user performed an action that should issue an OTP | Identity, purpose, environment, request time, and request count |
| Delivered | A message containing a candidate code reached the recipient | Which request this message belongs to; do not infer solely from inbox position |
| Current | The verifier considers this code eligible under product policy | Whether another request, resend, timeout, or failed-attempt rule can change that status |
| Expired | The validity window ended | Submission must fail without producing the protected state transition |
| Consumed | A valid code completed its intended action | The resulting account/session state and the policy for another use |
| Rejected | The code is wrong, stale, expired, over-attempted, or otherwise invalid | The exact reason visible to the system, even if the user-facing error intentionally stays generic |
Experiment A — establish the boring happy path first
- 1
Create one clean request boundary
Use a recipient and identity whose current OTP state you understand. Record the action that requests the code and the approximate request time before touching resend.
- 2
Request exactly one OTP
Do not double-click or resend while establishing the baseline. One request gives you a reference case for delivery, message matching, validity, and the final state transition.
- 3
Match the message before extracting the number
Check recipient, sender, subject, purpose, environment, and request window. A six-digit string is not enough evidence that the message belongs to this authentication attempt.
- 4
Submit the current code once
Observe both the immediate verification response and the state that follows: verified action, authenticated session, approved transaction, or whatever the OTP protects.
- 5
Try the same code again when one-time use is part of the design
Record the replay result explicitly. A code described as one-time should not silently behave like a reusable password after successful consumption.
Experiment B — resend creates a fork in the timeline
Resend is where simplistic OTP tests lose track of reality. Request A can produce code A, then a resend creates request B and code B. The messages may not arrive in the same order the requests were made. 'Use the latest email in the inbox' is therefore not a reliable definition of current state.
Before testing resend, document the product rule. Some systems supersede an earlier code as soon as a new one is issued; other designs may permit more than one code for part of their validity windows. QA should prove the rule the product actually implements rather than importing an assumption from another authentication system.
Test both directions deliberately: submit code A after requesting B, then submit code B. Capture which one is accepted, which one is rejected, and whether the result matches the documented policy. If the product has a resend cooldown or request cap, test those as separate state transitions instead of treating a disabled button as sufficient evidence.
The resend matrix: stop calling every message 'the OTP'
| Moment | Candidate A | Candidate B | Assertion to own |
|---|---|---|---|
| After request A | Current candidate | Does not exist | A follows the normal happy path |
| Immediately after requesting B | Policy-dependent | New candidate | Issuing B changes A exactly as documented |
| Messages arrive B then A | Older request, later delivery | Newer request, earlier delivery | Arrival order does not redefine request order |
| Submit A | Accept or reject according to supersession policy | Still governed by product policy | The test knows why A should behave this way |
| Submit B | No longer the focus | Accept if current and otherwise valid | B produces the intended state only once according to policy |
Experiment C — test the clock instead of merely waiting near it
Expiry is a boundary condition, not a decorative countdown. Test a code comfortably inside its validity window and another after the server considers that window closed. The authoritative result comes from verification behavior, not from whether a frontend timer reached zero.
If the UI displays a countdown, also check the user experience around the boundary: whether submission is prevented or safely rejected, whether a fresh code can be requested, and whether the account or protected action remains unchanged after an expired attempt. Avoid writing a test that depends on hitting one exact millisecond unless the system contract truly requires that precision.
NIST's authenticator guidance treats OTP validity and replay resistance as verifier concerns and requires a given OTP to be accepted only once while valid for the OTP authenticators covered by that guidance. That is a useful security reference, but your email-delivered product mechanism still needs tests against its own documented lifetime and semantics.
Experiment D — wrong codes should change only the state you intend
| Probe | What to observe | Failure smell |
|---|---|---|
| One incorrect code | Safe rejection and unchanged protected state | A wrong code partially advances the flow |
| Repeated incorrect codes | Attempt counting or throttling follows policy | Unlimited guesses against a short numeric code |
| Correct code after mistakes | Behavior follows remaining-attempt policy | Client and server disagree about whether the attempt budget is exhausted |
| Request another code after failures | New request follows resend/rate-limit rules | Resend becomes a bypass around failed-attempt controls |
| Different identity's code | Rejected for this identity/action | Code is not bound to the intended verification context |
Rate limiting belongs in the OTP test plan, not only in a security review
Short numeric codes have a small enough search space that failed-attempt controls matter. NIST requires rate limiting for covered OTP verifier outputs below its entropy threshold. In product QA, verify the limits and recovery behavior your authentication design promises instead of assuming the UI button is the control.
Retries can secretly manufacture a new OTP scenario
Test-runner retries are dangerous when setup requests an OTP every time but the recipient survives across attempts. Attempt one can create code A, fail for an unrelated reason, and leave its message behind. Attempt two creates code B and may then read A, B, or whichever message a weak selector happens to find first.
Keep retry ownership explicit. A retry can provision a fresh identity and recipient, or the fixture can carry a correlation boundary strong enough to identify the new request. What it should not do is silently accept whichever numeric code appears newest and turn a nondeterministic test into a misleading green build.
The OTP test deck — split these into named cases
Keeping them separate makes a failure describe a product rule instead of collapsing the entire authentication mechanism into one giant scenario.
- One request, correct current code, successful protected state transition.
- Incorrect code with no unintended state change.
- Resend with explicit old-code and new-code behavior.
- Code submitted before and after the documented expiry boundary.
- Replay after successful use when the design promises one-time consumption.
- Repeated wrong attempts and the documented attempt limit or throttle.
- Rapid resend requests and resend cooldown or request-rate policy.
- Code delivered to or generated for the wrong identity, purpose, or environment.
- Out-of-order email delivery after multiple requests.
- Test-runner retry without accidentally consuming a previous attempt's code.
When the OTP test fails, name the broken clock
| Observed symptom | Likely layer to inspect first | Do not immediately blame |
|---|---|---|
| No email appears | Trigger, outbound queue, sender, routing, delivery | The code verifier |
| Email arrives but no code is identifiable | Template/content generation and extraction assumptions | SMTP latency |
| Old code works unexpectedly after resend | Documented supersession policy and verifier state | Inbox ordering |
| Fresh code is reported expired | Server-side lifetime, request timestamp, clock/state handling | The visible countdown alone |
| Correct code succeeds twice | Consumption/replay handling | Email delivery |
| Wrong attempts never slow or stop | Verifier attempt/rate-limit controls | The inbox UI |
Do not oversell email OTP as phishing-resistant authentication
This article tests the mechanism your product ships; it does not upgrade the security properties of the delivery channel. Keep QA claims precise: verify recency, validity, replay, attempts, and state transitions without describing email-delivered OTP as phishing-resistant simply because the code is one-time.
The best OTP test is a timeline you can explain. You know which request created each candidate, which candidate should be current, what resend changed, when validity ended, what wrong attempts consumed, and what happened after success. Once every code has a state instead of merely a number, stale-message bugs and false-green tests become much harder to hide.
Need a clean recipient for manual email QA?
Use MailOnce to inspect real signup, OTP, magic-link, password-reset, and notification messages without putting a personal inbox into the test fixture.
Create temporary emailSources & further reading
Primary security and test-runner documentation used to ground the testing guidance.
Continue testing
Move to the next boundary in the same email-testing problem space.