cluster:developer OTP Testing Magic Link Playwright Cypress

Testing OTP & Magic-Link Login in Playwright and Cypress

9 min read

Testing OTP & Magic-Link Login in Playwright and Cypress

To test OTP login in Playwright or Cypress reliably, give each test an isolated email address, submit that address through your own staging login flow, poll the inbox API with a deadline, validate the newest matching message, then enter the code or open the trusted magic link. Do not use a fixed sleep, a shared inbox, or the first six-digit string you find. Those shortcuts create flaky tests and can let one parallel worker authenticate as another.

This guide uses the documented Inboxto receive-only API. It is for applications and environments you are authorized to test, not for automating third-party account creation or bypassing a platform's verification rules. Inboxto receives email; it does not provide SMS numbers.

Why OTP and magic-link tests fail

The browser action and the email delivery happen in different systems. A naive test often assumes they are synchronous:

await page.getByRole('button', { name: 'Send code' }).click();
await page.waitForTimeout(5000);
// Hope the email exists now.

Five seconds may be too long on a fast run and too short during a normal queue delay. The harder failures are hidden:

  • two workers search the same inbox and read the wrong message;
  • a stale OTP from an earlier run matches the regular expression;
  • a resend invalidates the first code while the test still uses it;
  • a magic link belongs to a different browser session;
  • a broad URL match opens an untrusted host; or
  • the message arrives, but the application never marks the account as verified.

Treat delivery as a separate boundary. The earlier guide to automating email verification with a temp mail API explains the complete state machine; this article focuses on Playwright and Cypress implementations.

Build one inbox helper before framework code

Both test runners need the same small contract:

  1. create or reserve a unique address;
  2. list recent messages for that exact recipient;
  3. fetch one message body;
  4. reject messages older than the test start time;
  5. require the expected sender and subject; and
  6. return an OTP or an HTTPS link only after those checks pass.

The public Inboxto documentation currently uses https://inboxto.app/api as the base URL, Bearer API keys, POST /v1/addresses, GET /v1/messages?address=..., and GET /v1/messages/:id. It also states a default limit of 100 requests per hour and exposes rate-limit headers. Store INBOXTO_API_KEY in the test runner's secret storage and never print the key, message body, OTP, or magic-link token.

If you need the API setup first, use the disposable email API getting-started guide.

const INBOX_API = 'https://inboxto.app/api';

async function inbox(path, options = {}) {
  const response = await fetch(`${INBOX_API}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${process.env.INBOXTO_API_KEY}`,
      'Content-Type': 'application/json',
      ...options.headers
    }
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`Inbox API failed: ${response.status}`);
  return body.data;
}

function createTestAddress(address) {
  return inbox('/v1/addresses', {
    method: 'POST',
    body: JSON.stringify({
      address,
      display_name: 'Browser login test',
      type: 'custom',
      is_primary: false
    })
  });
}

async function waitForMessage({ address, startedAt, subject, timeoutMs = 60_000 }) {
  const deadline = Date.now() + timeoutMs;
  let delay = 2000;

  while (Date.now() < deadline) {
    const messages = await inbox(
      `/v1/messages?address=${encodeURIComponent(address)}&limit=10&unread=true`
    );
    const match = messages.find((message) =>
      message.to === address &&
      Date.parse(message.received_at || '') >= startedAt &&
      subject.test(message.subject || '') &&
      /@auth\.example\.test$/i.test(message.from || '')
    );

    if (match) return inbox(`/v1/messages/${encodeURIComponent(match.id)}`);
    await new Promise((resolve) => setTimeout(resolve, delay));
    delay = Math.min(Math.round(delay * 1.5), 8000);
  }

  throw new Error(`Expected login email did not arrive for ${address}`);
}

function extractOtp(message) {
  const source = message.text_body || message.html_body || '';
  const match = source.match(/(?:code|otp)\D{0,24}(\d{6})\b/i);
  if (!match) throw new Error('Expected six-digit OTP was not found');
  return match[1];
}

function extractMagicLink(message) {
  const source = message.html_body || message.text_body || '';
  const links = [...source.matchAll(/https:\/\/[^\s"'<>]+/g)].map(([url]) => url);
  const link = links.find((url) => new URL(url).hostname === 'staging.example.test');
  if (!link) throw new Error('Trusted magic link was not found');
  return link;
}

The regular expressions are examples, not universal parsers. Match the wording and code length in your own template. HTML entities, wrapped links, localization, or vendor redirect URLs may require an HTML parser or a stricter template-specific rule. There is no safe “magic regex” for every sender.

Playwright: test an email OTP login

Playwright fixtures are useful because they can give each test its own address and cleanup scope. The official fixture documentation distinguishes test-scoped and worker-scoped fixtures; for login mail, test-scoped isolation is the safer default unless you deliberately test a persistent account lifecycle.

This compact example creates the address in the test body so the full flow is visible:

import { test, expect } from '@playwright/test';

test('logs in with an email OTP', async ({ page }) => {
  const address = `pw-${Date.now()}-${test.info().workerIndex}@qa.example.test`;
  await createTestAddress(address);

  const startedAt = Date.now();
  await page.goto('https://staging.example.test/login');
  await page.getByLabel('Email').fill(address);
  await page.getByRole('button', { name: 'Send code' }).click();

  const message = await waitForMessage({
    address,
    startedAt,
    subject: /sign[- ]?in code/i
  });
  const otp = extractOtp(message);

  await page.getByLabel('Verification code').fill(otp);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page).toHaveURL(/\/account$/);
  await expect(page.getByTestId('session-status')).toHaveText('Signed in');
});

The final assertions matter more than the code entry. A filled OTP field does not prove authentication; the authoritative result is the application's session state or account page.

Playwright: test a magic link in the right context

Magic links may be bound to browser state or designed for a fresh context. Follow your application's intended behavior rather than always opening a new page. If the link should complete the request in the same session:

test('logs in with a magic link', async ({ page }) => {
  const address = `magic-${Date.now()}-${test.info().workerIndex}@qa.example.test`;
  await createTestAddress(address);

  const startedAt = Date.now();
  await page.goto('https://staging.example.test/login');
  await page.getByLabel('Email').fill(address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await waitForMessage({
    address,
    startedAt,
    subject: /magic link|sign in/i
  });
  const link = extractMagicLink(message);

  await page.goto(link);
  await expect(page).toHaveURL(/\/account$/);
  await expect(page.getByTestId('session-status')).toHaveText('Signed in');
});

Add a second assertion that the same link cannot create another session after use. Do not place the full URL in a trace annotation or failure message: its token is a credential.

Cypress: poll through a custom command

Cypress retries linked queries and assertions, but cy.request() is a non-query command and does not automatically become an inbox polling loop. The official retry-ability guide says non-queries execute once. Build the deadline explicitly and keep the command's returned chain intact.

Cypress.Commands.add('createTestAddress', (address) => {
  return cy.request({
    method: 'POST',
    url: 'https://inboxto.app/api/v1/addresses',
    headers: { Authorization: `Bearer ${Cypress.env('INBOXTO_API_KEY')}` },
    body: {
      address,
      display_name: 'Cypress OTP test',
      type: 'custom',
      is_primary: false
    },
    log: false
  });
});

Cypress.Commands.add('waitForEmailOtp', (address, startedAt) => {
  const deadline = Date.now() + 60_000;

  function poll() {
    return cy.request({
      url: 'https://inboxto.app/api/v1/messages',
      qs: { address, limit: 10, unread: true },
      headers: { Authorization: `Bearer ${Cypress.env('INBOXTO_API_KEY')}` },
      log: false
    }).then(({ body }) => {
      const match = body.data.find((message) =>
        message.to === address &&
        Date.parse(message.received_at || '') >= startedAt &&
        /sign[- ]?in code/i.test(message.subject || '') &&
        /@auth\.example\.test$/i.test(message.from || '')
      );

      if (match) {
        return cy.request({
          url: `https://inboxto.app/api/v1/messages/${encodeURIComponent(match.id)}`,
          headers: { Authorization: `Bearer ${Cypress.env('INBOXTO_API_KEY')}` },
          log: false
        }).then(({ body: detail }) => extractOtp(detail.data));
      }

      if (Date.now() >= deadline) throw new Error('OTP email did not arrive');
      return cy.wait(2500, { log: false }).then(poll);
    });
  }

  return poll();
});

it('logs in with an email OTP', () => {
  const address = `cy-${Date.now()}@qa.example.test`;
  const startedAt = Date.now();

  cy.createTestAddress(address);
  cy.visit('/login');
  cy.findByLabelText('Email').type(address);
  cy.findByRole('button', { name: 'Send code' }).click();
  cy.waitForEmailOtp(address, startedAt).then((otp) => {
    cy.findByLabelText('Verification code').type(otp, { log: false });
  });
  cy.findByRole('button', { name: 'Verify' }).click();
  cy.location('pathname').should('eq', '/account');
});

The example suppresses the command log while typing the OTP, but your CI video or application logging may still expose it. Review all artifacts produced by the runner, not only terminal output.

For a Cypress magic-link test, reuse the same inbox polling command but return extractMagicLink(detail.data), validate the host, then call cy.visit(link, { log: false }). If your authentication server sets cookies for another origin, follow Cypress's current cross-origin guidance rather than disabling browser security.

Persistent custom-domain addresses: the practical advantage

Shared disposable domains can be rejected by a staging policy or create noisy ownership. A custom domain gives the team stable naming such as otp-<run-id>@qa.example.test, and Inboxto's persistent addresses let a deliberately retained test account receive later reset or re-verification messages.

That does not guarantee acceptance by a third-party platform and must not be marketed as a bypass. Use a custom domain because you control the test namespace and lifecycle. The custom-domain temporary email walkthrough covers the setup boundary.

First-hand Inboxto editorial observation, reviewed September 22, 2026: the most reliable shared helper is not getSixDigitCode(). It is getExpectedMessage({ recipient, sender, subject, startedAt }), followed by a template-specific extractor. Keeping message identity separate from token extraction prevents an old but syntactically valid OTP from passing the wrong test.

Failure diagnostics that do not leak credentials

When a test times out, report stages rather than secrets:

Login request accepted: yes
Recipient hash: 8f31…
Inbox queried until: 60s
Matching sender found: no
Matching subject found: no

Do not log the raw address if it contains a user name, and never log the OTP, full message body, magic link, API key, or authentication cookie. Keep the received message available long enough to diagnose a failed test, then apply your normal test-data retention policy.

Final checklist

  • Use one address per test or worker unless persistence is the behavior under test.
  • Poll with a deadline instead of sleeping once or retrying forever.
  • Match recipient, sender, subject, and timestamp before extracting a token.
  • Use a template-specific OTP or link extractor; do not promise universal parsing.
  • Allow only trusted HTTPS hosts for magic links.
  • Assert the resulting session or account state, not just UI input.
  • Keep API keys, OTPs, links, cookies, and message bodies out of logs and artifacts.
  • Remember that Inboxto handles email, not SMS.

For a production-ready architecture, start with Inboxto for developers, create a scoped API key, and prove one authorized staging login flow before adding more templates.

Sources checked

The public API contract and test-runner behavior were checked on September 22, 2026. Framework APIs, plan limits, and message templates can change; use current official documentation and your own staging contract as the final authority.

Ready to Protect Your Privacy?

Create your first anonymous email address in seconds. No registration required.

Get Started