Test OTP, MFA, magic link and password reset flows
Documentation navigation
Test signup verification, email and SMS one-time passwords, authenticator MFA, magic links and password resets end to end, including expiry, resend and one-time-use checks.
Sign-up, login and account recovery all depend on a short code or link reaching the right person at the right time. A unit test with a mocked mail or SMS provider can prove that your application asked for a message to be sent. It cannot prove that the message arrived, that it contained a valid code, or that the user could finish the journey before the code expired.
Those gaps are where authentication bugs usually hide: a template variable that renders an empty code, a resend that leaves the old code valid, a link that works twice, or an SMS that arrives after the test has given up. Checking them by hand means a real mailbox, a real phone and a stopwatch for every release.
MailSlurp gives each test its own email inbox, phone number or virtual authenticator, and APIs that wait for the message and read the code. Your end-to-end test drives the application exactly as a user would, then uses MailSlurp to receive what the user would receive.
Choose a method for each journey
Most applications combine several of these journeys. Each one needs a different MailSlurp resource and a different final assertion.
| Journey | Where the code or link arrives | MailSlurp resource | What the test should prove |
|---|---|---|---|
| Sign-up email verification | Inbox | The code or link verifies the account once | |
| Login MFA by email | Inbox | The code completes the challenge and expires on time | |
| Login MFA by SMS | SMS | Phone number | The SMS arrives within your time budget and the code is accepted |
| Authenticator app MFA (TOTP) | Generated from a shared secret | Virtual TOTP device | Enrollment pairs correctly and later sign-ins accept the code |
| Magic link sign-in | Link in an email | Inbox | The link signs the user in once and then stops working |
| Password reset | Link or code in an email | Inbox | The new password works, the old one fails and the link cannot be reused |
Set up the client
The examples use TypeScript with the MailSlurp JavaScript SDK and Playwright. The same API calls exist in every MailSlurp SDK.
npm install --save-dev @playwright/test mailslurp-client
Create an API key in the MailSlurp dashboard and expose it to your test runner as an environment variable:
import { MailSlurp } from 'mailslurp-client';
const apiKey = process.env.MAILSLURP_API_KEY;
if (!apiKey) throw new Error('Set MAILSLURP_API_KEY');
export const mailslurp = new MailSlurp({ apiKey });
Test email verification codes
Email codes are the most common second factor and the most common way to verify a new account.
Create an inbox for each test
Give every test a fresh inbox so that parallel runs never read each other’s codes. An expiring inbox cleans itself up if a test crashes before its own cleanup runs.
const inbox = await mailslurp.createInboxWithOptions({ expiresIn: 300_000 });
// Use inbox.emailAddress as the user's email in your application.
Wait for the code and extract it
After your test submits the sign-up or login form, wait for the newest unread email in that inbox. The getEmailCodes endpoint finds the verification code for you, so the test does not depend on the exact wording of the template.
const email = await mailslurp.waitForLatestEmail(inbox.id, 60_000, true);
const result = await mailslurp.emailController.getEmailCodes({ emailId: email.id });
if (!result.found || !result.code) {
throw new Error(`No verification code found: ${result.warnings.join(', ')}`);
}
const code = result.code;
If your codes follow a strict format, a regular expression on email.body works too. Keep the code as a string so that leading zeros survive.
Complete the journey in the browser
The final assertion belongs to your application, not to MailSlurp. Enter the code where a user would and check the state the user should reach.
Full Playwright test for sign-up email verification
import { test, expect } from '@playwright/test';
import { MailSlurp } from 'mailslurp-client';
const mailslurp = new MailSlurp({ apiKey: process.env.MAILSLURP_API_KEY! });
test('new user verifies their email with a one-time code', async ({ page }) => {
test.setTimeout(120_000);
const inbox = await mailslurp.createInboxWithOptions({ expiresIn: 300_000 });
try {
await page.goto('https://app.example.com/sign-up');
await page.getByLabel('Email').fill(inbox.emailAddress);
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Create account' }).click();
const email = await mailslurp.waitForLatestEmail(inbox.id, 60_000, true);
const { found, code } = await mailslurp.emailController.getEmailCodes({ emailId: email.id });
expect(found).toBe(true);
await page.getByLabel('Verification code').fill(code!);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
} finally {
await mailslurp.deleteInbox(inbox.id);
}
});Replace the URL, labels and final heading with your application’s own. Keep the test timeout longer than the email wait so that a slow message fails with a clear MailSlurp timeout rather than a generic runner timeout.
Test SMS verification codes
SMS codes need a real phone number that your application can send to. Provision a number in MailSlurp, then reserve it for the suite that uses it. Phone numbers are rented on Pro, Growth and Enterprise plans; see phone and SMS setup.
Record the time just before you trigger the SMS so that the wait ignores older messages on the same number:
const phoneNumberId = process.env.MAILSLURP_PHONE_NUMBER_ID;
if (!phoneNumberId) throw new Error('Set MAILSLURP_PHONE_NUMBER_ID');
const phone = await mailslurp.phoneController.getPhoneNumber({ phoneNumberId });
const smsSince = new Date();
// Enter phone.phoneNumber in your application and request the code here.
const sms = await mailslurp.waitController.waitForLatestSms({
waitForSingleSmsOptions: {
phoneNumberId: phone.id,
since: smsSince,
timeout: 60_000,
unreadOnly: true,
},
});
const match = /\b(\d{6})\b/.exec(sms.body);
if (!match) throw new Error(`No six-digit code in SMS: ${sms.body}`);
const smsCode = match[1];
Adjust the pattern to your sender’s message format. When several test workers need SMS at the same time, give each worker its own number with phone number pools instead of sharing one.
Test authenticator app MFA (TOTP)
Authenticator codes are not delivered anywhere. The application and the authenticator app each calculate the code from a shared secret and the current time. To test this, pair a MailSlurp virtual TOTP device with the secret your application issues during enrollment.
Most enrollment screens show a QR code that encodes an otpauth:// URL. Read that URL, or the base32 secret shown beside it, from the page or your test API:
const device = await mailslurp.mfaController.createTotpDeviceForOtpAuthUrl({
createTotpDeviceOtpAuthUrlOptions: { otpAuthUrl },
});
const { code } = await mailslurp.mfaController.getTotpDeviceCode({
id: device.id,
minSecondsUntilExpire: 5,
});
// Enter code in the application's MFA challenge.
minSecondsUntilExpire asks MailSlurp for a code with at least that many seconds left in its time step, which avoids typing a code that expires mid-submit. If you only have the base32 secret, use createTotpDeviceForCustom with { secret }. Keep the device for later sign-in tests of the same user and delete it when the user is removed. MFA TOTP devices covers pairing values and enrollment in more detail.
Test magic links and password resets
Links need the same care as codes: the right link must arrive, work once and then stop working.
Find the link in the email
Wait for the email by subject so that a welcome or marketing message in the same inbox is not mistaken for the sign-in email, then read its links:
const email = await mailslurp.waitController.waitForMatchingFirstEmail({
inboxId: inbox.id,
timeout: 60_000,
unreadOnly: true,
matchOptions: { matches: [{ field: 'SUBJECT', should: 'CONTAIN', value: 'Sign in' }] },
});
const { links } = await mailslurp.emailController.getEmailLinks({ emailId: email.id });
const magicLink = links.find((link) => link.includes('/auth/magic'));
if (!magicLink) throw new Error('Sign-in link not found');
await page.goto(magicLink);
Check what happens after the link is used
For a magic link, assert that the user is signed in, then open the same link again and expect it to be rejected. For a password reset, set a new password and check three things: the new password signs in, the old password no longer does, and the reset link refuses a second use. Use the Next.js Auth magic link guide for a complete framework example.
Cover the cases that break in production
A test that only follows the happy path misses most authentication defects. Add these cases once the basic journey passes.
| Case | How to test it with MailSlurp | Expected result |
|---|---|---|
| Code expiry | Wait for the code, hold it past your expiry window, then submit it | The application rejects it with a clear message |
| Resend | Request a second code, then use waitController.waitForMatchingEmails with count: 2 and submit the first code |
Only the newest code works |
| One-time use | Submit a valid code, sign out, submit the same code again | The second attempt fails |
| Wrong code limit | Submit incorrect codes until your limit, then the correct one | The account or challenge locks as designed |
| Delivery time | Record the time before triggering and compare it with the message’s createdAt |
The message arrives within your budget |
| Address variants | Use an inbox address with capitals or a plus suffix in sign-up | The code still reaches the user and verifies the account |
| Authenticator time step | Request TOTP codes with and without minSecondsUntilExpire near the boundary |
Your accepted time window matches your policy |
Keep authentication tests reliable in CI
- Create one inbox per test and set
expiresInso that abandoned inboxes disappear. - Use
unreadOnlyor asincetime on waits so that a retry never reads a code from an earlier attempt. - Match by subject or sender when an inbox can receive more than one kind of message.
- Make the runner’s timeout longer than every MailSlurp wait inside the test.
- Delete inboxes in a
finallyblock; keep phone numbers and TOTP devices that other suites reuse. - Store the API key and phone number IDs as CI secrets, never in the repository.
Troubleshoot message waits explains what to check when a wait times out.
Framework and provider examples
These guides build the same journeys in specific tools:
| Tool or provider | Guide |
|---|---|
| Playwright | Test email and SMS with Playwright and SMS OTP and MFA with Playwright |
| Selenium | Selenium email testing and Selenium with Auth0 TOTP MFA |
| Auth0 | Playwright with Auth0 TOTP MFA |
| Page objects | Page object model for email and SMS MFA |
| Email OTP | Test one-time password emails |
| SMS 2FA | Test 2FA SMS codes |
| Postman | Test user authentication and MFA with Postman |
| ACCELQ | ACCELQ email and OTP testing |
| Any framework | Email and SMS integration testing guide |