How to Test OTP and 2FA Verification Codes in Automated Tests
The moment your tests have to read a one-time code out of an email, they get fragile. A regex that worked last sprint breaks the day marketing tweaks the template. Here’s how to pull OTP and verification codes out reliably — and how to wait for them — without scraping the body yourself.
The fragile way: regex the body
Almost everyone starts here:
const body = email.bodyText
const code = body.match(/\b\d{6}\b/)?.[0]It works until it doesn’t. The body also contains a phone number, an order id, a year (2026), or a zip code that matches \\b\\d{6}\\btoo. Or the code is four or eight digits. Or it’s rendered as 123 456. Or the email is HTML-only and bodyText is empty. Every one of those is a flaky test waiting to happen.
The reliable way: extracted codes
Devmailr scans every inbound email at receive time and pulls out candidate codes for you, best guess first:
const email = await waitForEmail(inbox._id)
const code = email.extractedCodes[0] // "482913"extractedCodes is ordered — index 0is the most likely verification code. It handles 4–8 digit codes, space/hyphen-grouped codes like 123 456, and codes that sit next to words like “verification code” or “one-time.” It skips years and other numeric noise. You read a field instead of babysitting a regex.
Wait specifically for a code
Better still: don’t wait for anyemail — wait for one that actually has a code. That sidesteps the race where a “welcome” email lands before the code email does:
curl -sf "$BASE/mailboxes/$ID/wait?has_code=true&timeout=60" -H "$AUTH" \
| jq -r '.extractedCodes[0]'has_code=true tells /waitto resolve only on an email that carries an extractable code. The list endpoint takes the same filter if you’d rather browse: GET /mailboxes/:id/emails?has_code=true.
When the code isn’t a six-digit number
Some codes don’t look like OTPs — a base32 token, an alphanumeric coupon, your own format. For those, hand Devmailr your own regex with match:
// Email says: "Your access code: AB12-CD34"
const email = await waitForEmail(
inbox._id,
'match=' + encodeURIComponent('access code: ([A-Z0-9-]+)'),
)
const token = email.matches[0] // "AB12-CD34"You get capture group 1 (or the whole match if there’s no group) back as matches. On /wait, match is also a filter — the request only resolves once an email whose subject or body matches your pattern arrives. Add match_flags=i for case-insensitive matching.
A full example
A complete login-OTP test in Node, end to end:
const inbox = await createInbox(`otp-${Date.now()}`)
// trigger your app to email a login code to inbox.address
await requestLoginCode(inbox.address)
const email = await waitForEmail(inbox._id, 'has_code=true')
const code = email.extractedCodes[0]
await submitLoginCode(code)
// clean up so the inbox doesn't count against your plan
await fetch(`${BASE}/mailboxes/${inbox._id}`, { method: 'DELETE', headers })See the CI/CD guide for the createInbox and waitForEmail helpers used here.
Email OTP vs. authenticator (TOTP) codes
One clarification, because it trips people up. This is about codes delivered by email (and the same idea works for SMS if your provider supports it). Codes from an authenticator app — Google Authenticator, Authy — are TOTP: generated locally from a shared secret and never sent anywhere. You can’t “receive” those; you generate them in the test from the secret with a library like otplib. A disposable inbox is for the codes that actually arrive in a mailbox.
Remember: extractedCodes are heuristics. For an unusual layout, fall back to a precise match regex or read bodyText directly — the raw message is always there.
Related reading
Try it in two minutes
Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.