How to Test OTP Emails Automatically

By Denis · Published 2026-09-17.

One-time passwords close the loop on login, 2FA and sensitive actions, which makes them the most valuable email your app sends, and the one teams most often test by hand. Here is the automated version.

The loop you are automating

Every email OTP flow is the same five beats: submit the identifier (email or username), the app sends a code, your test reads the mailbox, extracts the digits, feeds them back and asserts success. None of those steps needs a human, the only missing piece used to be a mailbox the test could read. A disposable Tempx inbox is exactly that: pick any name, mail arrives through our MX, and GET /api/inbox?address=… returns it as JSON, the same inbox API the Tempx web app itself uses.

Where the code lives

The inbox API returns, per message: subject, from, and preview, the first 140 characters of the text part. OTP templates almost always open with the code, so the preview is the sweet spot. Two rules make extraction reliable:

// otp-test.mjs: request, poll, extract, verify
const ADDR = "otp-" + Date.now().toString(36) + "@tempx.uk";

await api.post("/login/otp", { email: ADDR }); // 1+2. request the code

let code = null; // 3. poll the inbox
for (let i = 0; i < 30 && !code; i++) {
 const res = await fetch("https://tempx.uk/api/inbox?address=" + ADDR);
 const { messages } = await res.json();
 const msg = messages.find((m) => /your login code/i.test(m.subject));
 if (msg) code = (msg.preview.match(/code is (\d{6})/) || [])[1];
 if (!code) await new Promise((r) => setTimeout(r, 2000));
}

if (!code) throw new Error("no OTP arrived"); // 4. extract (above)

const done = await api.post("/login/otp/verify", // 5. feed it back
 { email: ADDR, code });
if (!done.ok) throw new Error("OTP rejected");

The three flakiness traps

Let an agent do the clicking

If your E2E layer is an AI agent rather than a script, Tempx's MCP server has a tool built for this: wait_for_email blocks server-side until a matching message lands (5–175 s), so the agent's loop is submit → wait → read → continue with no polling code at all. See the MCP integration.

Framework-specific versions of the loop: Playwright and Cypress.

The failure modes that deserve assertions

The five-beat loop proves OTP delivery; the security of the feature lives in its edges. The ones worth automating: expiry, request, wait out the TTL (staging-short), submit, expect polite rejection, and confirm the message says expired, not wrong, because the distinction teaches users what to do next. Wrong-attempt lockout, three bad codes throttle or lock the channel without locking the account. Resend invalidation, if the policy is "newest code wins," a second request must kill the first code; test it by ordering the two mails and using the older one last. Code reuse, a successful login consumes the code; replaying it must fail.

Two softer checks round it out: the code must never appear in the URL after login (a disturbingly common regression), and the request endpoint must rate-limit without leaking whether the address has an account. Each is a small spec; together they turn the OTP flow from "works" into "fails safely."

Email OTP is not TOTP, test them separately

Two different features wear the "one-time code" badge. Email OTP arrives in a mailbox the user does not control the timing of, your tests own that mailbox, so the whole loop above applies. Authenticator TOTP (the six digits from an app) is generated on the user's device on a clock, your tests must either compute codes (shared secret plus a TOTP implementation) or use a staging bypass, and the interesting assertions shift to clock skew tolerance, replay windows and enrollment recovery. The overlap is small, and suites that conflate them end up testing neither properly.

Fallback and recovery are where they meet. "Email me a code instead" and "use my recovery codes" are the paths users take at 2 a.m. with a lost phone, and both are email-or-secret flows the disposable-inbox setup already covers. If the product has them, they belong in the same scheduled lane as the OTP spec, recovery regressions are the ones that generate support tickets, not bug reports.

Fan-out discipline for parallel suites

OTP specs poll, and polling suites multiply. The arithmetic: the inbox API allows 240 reads per minute per client; a two-second poll from each waiting test means about eight concurrently-waiting tests per CI machine before a 429 becomes possible. Under that, do nothing. Above it, either stretch the interval under load or centralize: one poller watching a set of addresses and handing results to waiters costs a dozen lines in the fixture and removes the ceiling entirely.

The same discipline answers flakiness: a wider subject match beats a longer timeout almost every time, because OTP mail that is late is often late, while OTP mail that never matches is a template change someone shipped on a Friday. Keep timeouts honest, keep matches precise, and the suite stays a signal instead of a nuisance.

The OTP matrix, one grid to keep honest

The full behavior contract fits in one grid, and each cell is a small spec: correct code, first use (succeeds and consumes the code; correct code, second use) rejected as used, not as wrong; expired code (rejected as expired with wording that says so; wrong code) counts toward lockout; wrong code past threshold (channel throttled, account intact; second request after first) per policy: newest-wins or both-valid-single-use; and the quiet cell teams forget, code never appears in any URL or log line after success. Seven cells, seven specs, and a regression in any of them becomes a named failure instead of a user report.

Keep the grid in the suite's README rather than in anyone's memory: the cells are obvious once written and invisible until then, and the spec list that falls out of the grid is the review artifact your security-minded colleague will enjoy reading.

Template changes: the regression nobody watches

OTP extraction is a contract with your own marketing team, and the contract breaks silently: a redesigned template moves the code below the fold of the preview, a "helpful" new footer adds a digit run that satisfies the loose regex, a localization pass changes the label the matcher anchors on. The guard is one canary spec that sends the real template to a disposable inbox and asserts the extraction still works, run it in the scheduled lane, and a template regression surfaces as a named failure the morning after deploy instead of as a support graph that bends upward for a week.

Include the text-part assertion in the same canary: the code must appear in the plain-text alternative, because the API returns it and text-only clients render it. One spec, two sentences of setup, and the most common silent OTP breakage (invisible in any browser-based preview) becomes visible by construction.

Create a temporary email address

Free, instant, no sign-up, messages auto-delete.

Go to your inbox