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 | Email | Inbox | The code or link verifies the account once |
| Login MFA by email | 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](/docs/testing/#choose-your-sdk-and-test-framework).

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

```typescript

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.

```typescript
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.

```typescript
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.

<details class="docs-disclosure" data-component="DocsDisclosure">
<summary>Full Playwright test for sign-up email verification</summary>

```typescript

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.

</details>

## 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](/docs/txt-sms/).

Record the time just before you trigger the SMS so that the wait ignores older messages on the same number:

```typescript
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](/docs/phone-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:

```typescript
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](/docs/totp/) 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:

```typescript
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](/guides/test-next-auth-magic-links/) 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 `expiresIn` so that abandoned inboxes disappear.
- Use `unreadOnly` or a `since` time 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 `finally` block; 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](/docs/wait-troubleshooting/) 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](/docs/playwright/) and [SMS OTP and MFA with Playwright](/guides/test-sms-otp-mfa-using-playwright/) |
| Selenium | [Selenium email testing](/docs/selenium/) and [Selenium with Auth0 TOTP MFA](/examples/selenium-auth0-mfa-login-test-totp-api/) |
| Auth0 | [Playwright with Auth0 TOTP MFA](/examples/auth0-playwright-totp-mfa-test-automation/) |
| Page objects | [Page object model for email and SMS MFA](/guides/page-object-model-e2e-mfa-testing/) |
| Email OTP | [Test one-time password emails](/guides/otp-one-time-password-email-test/) |
| SMS 2FA | [Test 2FA SMS codes](/guides/testing-2fa-otp-sms-codes/) |
| Postman | [Test user authentication and MFA with Postman](/blog/testing-user-authentication-mfa-postman/) |
| ACCELQ | [ACCELQ email and OTP testing](/docs/accelq/) |
| Any framework | [Email and SMS integration testing guide](/docs/testing/) |
