Test passwordless flows
This guide shows you how to automate passwordless authentication testing with Playwright by intercepting the one-time code email. Learn more about testing passwordless flows with Kinde.
The examples use Mailosaur, but any inbox service with an API for reading emails programmatically (for example MailSlurp or Mailtrap) works the same way: give each test a unique inbox, wait for the code email, extract the code, and enter it in the form.
Kinde one-time codes are always 6 digits, are generated fresh for every request, and expire after 2 hours (120 minutes) by default (configurable per environment). There is no static “test code”: intercepting the real email is the supported way to automate these flows.
What you need
Link to this section- Completed the Playwright setup
- A test user and a running Kinde application.
- A Mailosaur account (Sign up for free trial)
Keep OTP tests lean
Link to this sectionWaiting on real email delivery is the slowest and most failure-prone part of any test suite. We recommend covering the passwordless UI with a small number of smoke tests, and authenticating the rest of your suite another way: reuse authenticated state with Playwright’s storageState (see Test authenticated features), use an email + password test user, or use a machine-to-machine (M2M) application for API-level setup.
Setup Mailosaur
Link to this section-
Sign in to your Mailosaur account.
-
Copy the Server ID from the dashboard.
-
Go to API Keys > select Create standard key and copy the value.
-
Install the Mailosaur client in your Playwright project:
Terminal window npm i -D mailosaurTerminal window pnpm add -D mailosaurTerminal window yarn add -D mailosaurTerminal window bun add -d mailosaur -
Add the following to your
.envfile:# ... other environment variables ...MAILOSAUR_API_KEY=your-api-keyMAILOSAUR_SERVER_ID=your-server-idImportantMake sure to add the
.envfile to your.gitignorefile to prevent committing sensitive information to source control. -
Create a helper utility file
tests/utils/mailosaur.ts:Terminal window mkdir -p tests/utilstouch tests/utils/mailosaur.ts -
Add the following code to
mailosaur.ts:tests/utils/mailosaur.ts import MailosaurClient from "mailosaur"require("dotenv").config()const mailosaur = new MailosaurClient(process.env.MAILOSAUR_API_KEY!)const serverId = process.env.MAILOSAUR_SERVER_ID!export async function getOTPFromEmail(email: string,receivedAfter?: Date,): Promise<{emailId: string | undefinedotpCode: stringdeleteEmail: () => Promise<void>}> {// Pass receivedAfter when a test reuses an inbox across runs, so you// never pick up a stale code from a previous run's email.const message = await mailosaur.messages.get(serverId,{ sentTo: email },{ receivedAfter },)// Kinde codes are always exactly 6 digitsconst otpMatch = message.text?.body?.match(/\b(\d{6})\b/)const otpCode = otpMatch?.[1]if (!otpCode) {throw new Error("Could not extract OTP code from email")}return {emailId: message.id,otpCode,deleteEmail: async () => {await mailosaur.messages.del(message.id!)},}}
Write resilient tests
Link to this sectionTwo things to know before writing selectors against Kinde’s hosted pages:
- The screens around the code step evolve. For example, enabling passkeys adds a passkey option to the sign-in screen, and passing
login_hintcan pre-fill (or skip) the email step. Prefer role- and label-based selectors (getByRole,getByLabel) over positional ones, and keep the authentication methods on your test environment limited to the ones you are actually testing. - The code entry step itself is stable. The code input is
input[name="p_confirmation_code"](labelled “Code”) and accepts exactly 6 digits.
Test passwordless sign-up flow
Link to this sectionTo test the passwordless sign-up flow, enable the Email + code option in authentication methods in your Kinde application.
-
Create a new test file:
Terminal window touch tests/passwordless-signup.spec.ts -
Add the following code:
tests/passwordless-signup.spec.ts import { test, expect } from "@playwright/test"import { getOTPFromEmail } from "./utils/mailosaur"const serverId = process.env.MAILOSAUR_SERVER_ID!test.describe("Passwordless Authentication Flows", () => {test("user can sign up with email and code", async ({ page }) => {// Generate unique email for this test runconst timestamp = Date.now()const testEmail = `test-${timestamp}@${serverId}.mailosaur.net`await page.goto(process.env.TEST_APP_URL!)await page.click('[data-testid="sign-up-button"]')// Wait for Kinde sign-up pageawait expect(page).toHaveURL(/kinde\.com/)// Fill sign-up formawait page.fill('input[name="p_first_name"]', "John")await page.fill('input[name="p_last_name"]', "Doe")await page.fill('input[name="p_email"]', testEmail)await page.check('input[name="p_has_clickwrap_accepted"]') // Accept terms and conditionsawait page.click('button[type="submit"]')// Wait for OTP email and extract codeconst { otpCode, deleteEmail } = await getOTPFromEmail(testEmail)try {// Enter OTPawait page.fill('input[name="p_confirmation_code"]', otpCode)await page.click('button[type="submit"]')// Wait for successful authconst appUrl = new URL(process.env.TEST_APP_URL!)await expect(page).toHaveURL(new RegExp(appUrl.hostname.replace(".", "\\.")),)await expect(page.locator('[data-testid="user-profile"]')).toBeVisible()} finally {// Clean up OTP emailawait deleteEmail()}})})
Test passwordless sign-in flow
Link to this sectionTo test the passwordless sign-in flow:
-
Enable the Email + code option in Authentication methods.
-
Sign up for a new test user using the Mailosaur domain (e.g., test-123@your-server-id.mailosaur.net).
-
Save the test user email to your
.envfile asTEST_USER_EMAIL. -
Create a new test file:
Terminal window touch tests/passwordless-signin.spec.ts -
Add the following code:
tests/passwordless-signin.spec.ts import { test, expect } from '@playwright/test'import { getOTPFromEmail } from './utils/mailosaur'test.describe('Passwordless Authentication Flows', () => {test('user can sign in with email and code', async ({ page }) => {// This test reuses one inbox on every run, so record the start time and// pass it to the helper to avoid reading a code from a previous run.const testStart = new Date()await page.goto(process.env.TEST_APP_URL!)await page.click('[data-testid="sign-in-button"]')// Wait for Kinde sign-in pageawait expect(page).toHaveURL(/kinde\.com/)// Fill sign-in formawait page.fill('input[name="p_email"]', process.env.TEST_USER_EMAIL!)await page.click('button[type="submit"]')// Wait for OTP email and extract code (only emails received after this test started)const { otpCode, deleteEmail } = await getOTPFromEmail(process.env.TEST_USER_EMAIL!,testStart)try {// Enter OTPawait page.fill('input[name="p_confirmation_code"]', otpCode)await page.click('button[type="submit"]')// Wait for successful authconst appUrl = new URL(process.env.TEST_APP_URL!)await expect(page).toHaveURL(new RegExp(appUrl.hostname.replace('.', '\\.')))await expect(page.locator('[data-testid="user-profile"]')).toBeVisible()} finally {// Clean up OTP emailawait deleteEmail()}})})
Test sign-up flow with email and password
Link to this sectionWe have included this example because OTP verification is required for all new users in Kinde regardless of the authentication method used.
This example uses the Kinde email + password authentication method to sign up a new test user.
-
Create a new test file:
Terminal window touch tests/email-password-signup.spec.ts -
Add the following code:
tests/email-password-signup.spec.ts import { test, expect } from '@playwright/test'import { getOTPFromEmail } from './utils/mailosaur'const serverId = process.env.MAILOSAUR_SERVER_ID!test.describe('Authentication Flows', () => {test('user can sign up with email and password', async ({ page }) => {// Generate unique email for this test runconst timestamp = Date.now()const testEmail = `test-${timestamp}@${serverId}.mailosaur.net`await page.goto(process.env.TEST_APP_URL!)await page.click('[data-testid="sign-up-button"]')// Wait for Kinde sign-up pageawait expect(page).toHaveURL(/kinde\.com/)// Fill sign-up formawait page.fill('input[name="p_first_name"]', 'John')await page.fill('input[name="p_last_name"]', 'Doe')await page.fill('input[name="p_email"]', testEmail)await page.check('input[name="p_has_clickwrap_accepted"]') // Accept terms and conditionsawait page.click('button[type="submit"]')// Wait for OTP emailconst { otpCode, deleteEmail } = await getOTPFromEmail(testEmail)try {// Enter OTPawait page.fill('input[name="p_confirmation_code"]', otpCode)await page.click('button[type="submit"]')// Enter passwordawait page.fill('input[name="p_first_password"]',process.env.TEST_USER_PASSWORD!)await page.fill('input[name="p_second_password"]',process.env.TEST_USER_PASSWORD!)await page.click('button[type="submit"]')// Wait for successful authconst appUrl = new URL(process.env.TEST_APP_URL!)await expect(page).toHaveURL(new RegExp(appUrl.hostname.replace('.', '\\.')))await expect(page.locator('[data-testid="user-profile"]')).toBeVisible()} finally {// Clean up OTP emailawait deleteEmail()}})})
Run the tests
Link to this section-
Use the following command:
Terminal window npx playwright test --uiTerminal window pnpm dlx playwright test --uiTerminal window yarn dlx playwright test --uiTerminal window bunx playwright test --ui -
Select the test spec to run.
You should now see the Playwright tests passing in the browser.
-
Go to your Kinde dashboard > Users to find the test users you created.