otpbox

OTPBox / Guides / 2FA & SSO

2FA & SSO

Testing 2FA and SSO/magic-link flows

"Email verification" isn't one flow, it's several. Signup OTP is the common case, but a login can also demand a second-factor code, a passwordless login can hand you a clickable link instead of a code, and a "remember this device" checkbox can change whether the second factor fires at all. All three are testable with the same real disposable inbox — the difference is what you do with the message once it arrives, and when you trigger it.

1. Email-based 2FA on login

The shape is identical to signup OTP — create an inbox, use its address as the account's email, wait for a code — except the trigger is a login attempt on an already-registered account, not a signup form. The account has to exist first with that inbox's address as its email, so most 2FA tests are really two steps: register (or seed) the account, then log in and clear the 2FA prompt.

import { OTPBox } from 'otpbox-sdk';

const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY });
const inbox = await client.createInbox();

// 1. Register the account with inbox.address (own signup flow, own OTP step
//    if signup itself requires verification - see the Playwright guide)

// 2. Trigger login, which fires a fresh 2FA email
await fetch('https://your-app.example.com/api/login', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email: inbox.address, password: 'Testpass123!' }),
});

const code = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
if (!code) throw new Error('no 2FA code arrived');

await fetch('https://your-app.example.com/api/login/verify', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email: inbox.address, code }),
});

The one gotcha specific to 2FA: waitForOtp() only considers messages received after the call started, so if signup already sent one code and login sends a second, waiting after the login call (not before) is what keeps you from reading the stale signup code.

2. Magic-link / passwordless login

Here the email contains a link, not a code, so the test navigates a browser straight to the extracted URL instead of typing anything. The full message response classifies the link's purpose via linkType (and the equivalent field on the message-list endpoint, linkHost/linkType) — one of verification, password_reset, magic_login, unsubscribe, tracking or general. A passwordless login email should classify as magic_login:

GET /api/v1/messages/:id
→ {
  "id": "msg_...",
  "from": "noreply@your-app.example.com",
  "subject": "Your login link",
  "text": "Click to sign in: https://your-app.example.com/auth/magic?t=...",
  "code": null,
  "link": { "url": "https://your-app.example.com/auth/magic?t=...", "host": "your-app.example.com", "type": "magic_login" },
  ...
}

With otpbox-sdk, waitForEmail() returns the message summary; fetch the full message to read link.url:

const message = await client.waitForEmail(inbox.id, { timeoutMs: 20_000 });
const full = await client.getMessage(message.id);
if (full.link?.type !== 'magic_login') throw new Error(`unexpected link type: ${full.link?.type}`);

Then, in whichever browser tool is driving the test, navigate directly to that URL instead of filling in a code field:

ToolCall
Playwrightawait page.goto(full.link.url)
Seleniumdriver.get(full["link"]["url"])
Puppeteerawait page.goto(full.link.url)

Since the link itself carries the auth token, there's no code field to fill and no submit button to click — the navigation is the login. Assert on whatever the app shows post-login (dashboard, welcome text, a session cookie) the same way you would after a code-based flow.

3. "Remember this device" — asserting 2FA does NOT re-trigger

A common feature next to email 2FA is a "remember this device for 30 days" checkbox, and the thing worth testing is the negative case: a second login from the same device (or the same cookie/token the app uses to recognize it) should not send another 2FA email. OTPBox makes that assertion straightforward because you control the inbox: log in twice against the same address, and check that only one 2FA message ever arrived.

const inbox = await client.createInbox();
// ...register account, log in once with "remember this device" checked,
// clear that first 2FA email...

// Second login, same session/cookie jar that "remembers" the device
await loginAgain(inbox.address);

// Give the app a moment, then assert nothing new arrived
const secondCheck = await client.waitForEmail(inbox.id, { timeoutMs: 5_000 });
if (secondCheck) throw new Error('2FA re-triggered on a remembered device');

A short timeout is appropriate here since you're asserting an absence, not waiting for something you expect — waitForEmail()/waitForOtp() returning null after the timeout is the passing case. Pair this with the positive case (a login from a fresh session does re-trigger 2FA) using a second inbox, so the test suite covers both directions of the "remember this device" logic.

Full example: Playwright end-to-end

Putting the login-2FA case together as a runnable test, including the account setup step:

import { test, expect } from '@playwright/test';
import { OTPBox } from 'otpbox-sdk';

test('login requires a real 2FA code', async ({ page }) => {
  const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
  const inbox = await client.createInbox();

  try {
    // Seed the account (own app-specific signup, own OTP step if needed)
    await page.goto('https://your-app.example.com/signup');
    await page.fill('[name="email"]', inbox.address);
    await page.fill('[name="password"]', 'Testpass123!');
    await page.click('button[type="submit"]');
    const signupCode = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
    await page.fill('[name="otp"]', signupCode!);
    await page.click('button[type="submit"]');

    // Log out, then log back in to trigger 2FA
    await page.click('text=Log out');
    await page.goto('https://your-app.example.com/login');
    await page.fill('[name="email"]', inbox.address);
    await page.fill('[name="password"]', 'Testpass123!');
    await page.click('button[type="submit"]');

    const loginCode = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
    expect(loginCode).toBeTruthy();
    await page.fill('[name="otp"]', loginCode!);
    await page.click('button[type="submit"]');
    await expect(page.locator('text=Dashboard')).toBeVisible();
  } finally {
    await client.deleteInbox(inbox.id);
  }
});

A note on webhooks for real-time assertions

Polling with waitForOtp()/waitForEmail() is the simplest option for a test suite, but if your setup can receive inbound HTTP, subscribing a webhook to otp.extracted and link.detected gets you the event the moment it happens rather than on the next poll tick — see Webhooks for the full event catalog and payload shapes.

Next steps

Ready to try it against your own app? Create a free account

← Back to OTPBox