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:
- Anchor to context over digits.
/code is (\d{6})/beats/\d{6}/the day your mail footer grows an order number. If you control the template, put a stable prefix in the text part. - Take the message by subject filter so a stray marketing mail can never satisfy the assertion.
// 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
- Shared inboxes. One address for the whole suite means one test can consume another's code. Unique address per test, it is free.
- Fixed sleeps.
sleep(10)is simultaneously too slow and not slow enough. Poll every 2 seconds with a 45-second deadline; assert the arrival time too if delivery speed matters to you. - Ignored rate limits. Inbox reads cap at 240/minute, fine for dozens of parallel tests at 2-second polls, but stagger a 100-worker fan-out or share one poller per worker.
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