Inbox
Back to Developers & QA

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

StateWhat happenedWhat QA should know
RequestedThe user performed an action that should issue an OTPIdentity, purpose, environment, request time, and request count
DeliveredA message containing a candidate code reached the recipientWhich request this message belongs to; do not infer solely from inbox position
CurrentThe verifier considers this code eligible under product policyWhether another request, resend, timeout, or failed-attempt rule can change that status
ExpiredThe validity window endedSubmission must fail without producing the protected state transition
ConsumedA valid code completed its intended actionThe resulting account/session state and the policy for another use
RejectedThe code is wrong, stale, expired, over-attempted, or otherwise invalidThe exact reason visible to the system, even if the user-facing error intentionally stays generic

Experiment A — establish the boring happy path first

  1. 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. 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. 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. 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. 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'

MomentCandidate ACandidate BAssertion to own
After request ACurrent candidateDoes not existA follows the normal happy path
Immediately after requesting BPolicy-dependentNew candidateIssuing B changes A exactly as documented
Messages arrive B then AOlder request, later deliveryNewer request, earlier deliveryArrival order does not redefine request order
Submit AAccept or reject according to supersession policyStill governed by product policyThe test knows why A should behave this way
Submit BNo longer the focusAccept if current and otherwise validB 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

ProbeWhat to observeFailure smell
One incorrect codeSafe rejection and unchanged protected stateA wrong code partially advances the flow
Repeated incorrect codesAttempt counting or throttling follows policyUnlimited guesses against a short numeric code
Correct code after mistakesBehavior follows remaining-attempt policyClient and server disagree about whether the attempt budget is exhausted
Request another code after failuresNew request follows resend/rate-limit rulesResend becomes a bypass around failed-attempt controls
Different identity's codeRejected for this identity/actionCode 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 symptomLikely layer to inspect firstDo not immediately blame
No email appearsTrigger, outbound queue, sender, routing, deliveryThe code verifier
Email arrives but no code is identifiableTemplate/content generation and extraction assumptionsSMTP latency
Old code works unexpectedly after resendDocumented supersession policy and verifier stateInbox ordering
Fresh code is reported expiredServer-side lifetime, request timestamp, clock/state handlingThe visible countdown alone
Correct code succeeds twiceConsumption/replay handlingEmail delivery
Wrong attempts never slow or stopVerifier attempt/rate-limit controlsThe 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 email

Sources & 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.