All posts
    Guides

    How to Test OTP and 2FA Verification Codes in Automated Tests

    The Devmailr Team7 min read

    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:

    javascript
    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:

    javascript
    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:

    bash
    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:

    javascript
    // 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:

    javascript
    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.