Skip to content

Testing passwordless flows

Passwordless authentication with email OTP provides a seamless user experience, but testing these flows presents a unique challenge: you need to programmatically capture and extract verification codes from emails. Unlike traditional password-based authentication, passwordless flows require coordinating between your test automation and email delivery systems.

Passwordless authentication flows send a one-time password (OTP) via email. To automate testing, you need to:

  1. Capture the OTP email - Use a test email service or custom infrastructure
  2. Extract the OTP code - Parse the code from the email content
  3. Enter the OTP - Submit the code in your test automation

Approach 1: Using a test email service

Link to this section

Test email services like Mailosaur or Mailtrap provide API access to test inboxes, making it easy to retrieve OTP codes programmatically.

Mailosaur provides dedicated test email addresses and an API to retrieve messages:

import MailosaurClient from 'mailosaur';
const mailosaur = new MailosaurClient(process.env.MAILOSAUR_API_KEY!);
const serverId = process.env.MAILOSAUR_SERVER_ID!;
const timestamp = Date.now()
const testEmail = `test-${timestamp}@${serverId}.mailosaur.net`
// Request OTP
await page.fill('input[name="p_email"]', testEmail);
await page.click('button[type="submit"]');
// Wait for OTP email
const email = await mailosaur.messages.get(serverId, {
sentTo: testEmail,
});
// Extract 6-digit OTP code from email
const otpMatch = email.text?.body?.match(/\b(\d{6})\b/);
const otpCode = otpMatch?.[1];
if (!otpCode) {
throw new Error('Could not extract OTP code from email');
}
// Enter OTP
await page.fill('input[name="p_confirmation_code"]', otpCode);
await page.click('button[type="submit"]');
// Clean up - delete the email
await mailosaur.messages.del(email.id!);

Mailtrap also provides API access to test inboxes:

import MailtrapClient from 'mailtrap';
const mailtrap = new MailtrapClient({
token: process.env.MAILTRAP_API_TOKEN!,
});
const inboxId = process.env.MAILTRAP_INBOX_ID!;
const timestamp = Date.now();
const testEmail = `test-${timestamp}@${process.env.MAILTRAP_DOMAIN}`;
// Request OTP
await page.fill('input[name="p_email"]', testEmail);
await page.click('button[type="submit"]');
// Wait for OTP email (polling)
let email = null;
const startTime = Date.now();
while (!email && Date.now() - startTime < 30000) {
const messages = await mailtrap.testing.inboxes.getMessages(inboxId);
email = messages.find((msg) => msg.to_email === testEmail);
if (!email) {
await new Promise((r) => setTimeout(r, 1000));
}
}
if (!email) {
throw new Error('Timeout waiting for OTP email');
}
// Extract OTP code
const otpMatch = email.html_body?.match(/\b(\d{6})\b/);
const otpCode = otpMatch?.[1];
// Enter OTP
await page.fill('input[name="p_confirmation_code"]', otpCode);
await page.click('button[type="submit"]');

Approach 2: Email-to-storage pipeline

Link to this section

If you have your own email infrastructure, you can route test emails to cloud storage (S3, database, etc.) and query them from your tests.

Route test emails to S3, then query from your tests:

utils/email-otp.ts
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
export async function waitForOTPEmail(
email: string,
timeoutMs = 30000
): Promise<string> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
try {
const command = new GetObjectCommand({
Bucket: 'your-test-emails-bucket',
Key: `${email}/latest.txt`,
});
const response = await s3.send(command);
const body = await response.Body?.transformToString();
const otpMatch = body?.match(/\b(\d{6})\b/);
if (otpMatch) {
return otpMatch[1];
}
} catch (e) {
// Email not arrived yet
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error('Timeout waiting for OTP email');
}

Using a database

Link to this section

Store emails in a database and query them:

utils/email-otp.ts
import { db } from './db';
export async function waitForOTPEmail(
email: string,
timeoutMs = 30000
): Promise<string> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const emailRecord = await db.emails.findOne({
where: { to: email },
orderBy: { createdAt: 'desc' },
});
if (emailRecord) {
const otpMatch = emailRecord.body?.match(/\b(\d{6})\b/);
if (otpMatch) {
return otpMatch[1];
}
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error('Timeout waiting for OTP email');
}

Approach 3: Dedicated test domain

Link to this section

Configure a subdomain’s email to be programmatically accessible through IMAP or a custom API:

import Imap from 'imap';
const imap = new Imap({
user: process.env.TEST_EMAIL_USER!,
password: process.env.TEST_EMAIL_PASSWORD!,
host: 'imap.example.com',
port: 993,
tls: true,
});
export async function getOTPFromEmail(
email: string,
timeoutMs = 30000
): Promise<string> {
return new Promise((resolve, reject) => {
imap.once('ready', () => {
imap.openBox('INBOX', false, () => {
imap.search(['UNSEEN', ['TO', email]], (err, results) => {
if (err) reject(err);
const fetch = imap.fetch(results, { bodies: '' });
fetch.on('message', (msg) => {
msg.on('body', (stream) => {
let body = '';
stream.on('data', (chunk) => {
body += chunk.toString('utf8');
});
stream.once('end', () => {
const otpMatch = body.match(/\b(\d{6})\b/);
if (otpMatch) {
resolve(otpMatch[1]);
}
});
});
});
});
});
});
imap.connect();
setTimeout(() => {
reject(new Error('Timeout waiting for OTP email'));
}, timeoutMs);
});
}

Best practices

Link to this section
  1. Use dedicated test email addresses: Create separate email addresses for each test run to avoid conflicts
  2. Add retry logic: Email delivery can be delayed; add polling with timeouts
  3. Clean up emails: Delete test emails after use to keep inboxes clean
  4. Handle timeouts gracefully: Set reasonable timeouts and provide clear error messages
  5. Extract OTP reliably: Use regex patterns that match your OTP format (typically 6 digits)
  6. Secure credentials: Store email service API keys in environment variables or secret managers

What’s not supported

Link to this section

The following limitations apply to passwordless flow testing:

  • Real email addresses: Testing with real email addresses requires manual code entry (use test email services instead)
  • SMS OTP: This guide focuses on email OTP; SMS OTP testing requires different approaches
  • Rate limiting: Some email services have rate limits; plan test execution accordingly

Test passwordless flows with: