Authentication Testing Guide

How to Test SMS OTP Login with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing SMS OTP login flows with Playwright. Twilio Verify test credentials, rate limit handling, OTP input autofill interception, expiry countdown timers, resend logic, phone number validation, and the pitfalls that break real OTP test suites.

150B+

Twilio has delivered over 150 billion messages through its platform, and Verify alone processes billions of OTP verifications per year across 230+ countries.

Twilio 2024 Annual Report

0sOTP expiry window
0Scenarios covered
0OTP input fields per code
0%Fewer lines with Assrt

SMS OTP Login End-to-End Flow

BrowserYour AppTwilio VerifySMS GatewayUser PhoneEnter phone numberPOST /Verifications (to: +1...)Send SMS with OTP codeDeliver SMS: Your code is 123456Submit OTP codePOST /VerificationCheck (code: 123456)status: approvedSet session, render dashboard

1. Why Testing SMS OTP Login Is Harder Than It Looks

SMS OTP login looks simple on the surface: the user enters a phone number, receives a six-digit code via text message, types the code into split input fields, and lands on a dashboard. In practice, testing this flow end-to-end is one of the most frustrating challenges in browser automation. The code travels through an external SMS gateway outside your control, the OTP expires after a short countdown window, the input fields use autofill behaviors that break standard Playwright fill commands, and rate limits on the verification API can throttle your entire CI pipeline.

There are six structural reasons this flow is hard to automate. First, SMS delivery is asynchronous and unreliable in test environments, so you cannot simply wait for the code to arrive on a real phone. Second, most OTP implementations use individual input fields for each digit (split inputs), with custom focus management and paste handlers that do not respond to Playwrightfill() the way standard text inputs do. Third, the OTP expires (typically in 30 to 120 seconds), adding a strict time constraint to your test execution. Fourth, Twilio Verify and similar services enforce per-phone rate limits that cap the number of verification attempts in a rolling window. Fifth, phone number validation varies by country, requiring E.164 formatting that your UI may or may not handle automatically. Sixth, browser autofill of SMS OTP codes (the WebOTP API and autocomplete="one-time-code") can silently fill inputs before your test types, causing race conditions.

SMS OTP Login Flow

🌐

Enter Phone

User submits phone number

⚙️

API Request

POST /send-otp

↪️

Twilio Verify

Send SMS via gateway

📧

SMS Delivered

6-digit code on phone

🌐

Enter OTP

Type code in split fields

⚙️

Verify Code

POST /verify-otp

Authenticated

Session created

OTP Failure and Retry Flow

Wrong Code

User enters invalid OTP

🌐

Error Message

Invalid code, try again

🔒

Retry Limit

Max attempts reached?

⚙️

Resend Code

Request new OTP

🔒

Rate Check

Twilio rate limit OK?

New Code Sent

Fresh OTP via SMS

A good SMS OTP test suite addresses all of these surfaces. The sections below walk through each scenario with runnable Playwright TypeScript that you can paste directly into your project.

2. Setting Up a Reliable Test Environment

The critical insight for testing SMS OTP in CI is that you must never rely on actual SMS delivery. Twilio Verify provides magic test phone numbers that always approve a specific code without sending a real message. For Twilio Verify, the phone number +15005550006 combined with the verification code 123456 will always succeed. This eliminates SMS delivery latency, cost, and reliability from your test pipeline entirely.

SMS OTP Test Environment Checklist

  • Create a dedicated Twilio account for testing (separate from production)
  • Enable Twilio Verify test credentials in the Verify Service settings
  • Register test phone numbers: +15005550006 (success), +15005550001 (invalid)
  • Set OTP expiry to a known value (e.g. 30 seconds) for deterministic tests
  • Configure your app to read TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN from env
  • Add a test API endpoint that returns the pending OTP for a given phone (test env only)
  • Disable browser WebOTP autofill in test mode to prevent race conditions
  • Set rate limit thresholds higher in test or use Twilio test credentials to bypass them

Environment Variables

.env.test

Test Helper: OTP Retrieval Endpoint

In production, your backend calls Twilio Verify to send the OTP and the code only exists on the user's phone. In test mode, you need a way for Playwright to retrieve the pending code. The cleanest approach is a test-only API route that returns the expected OTP for a given phone number. When using Twilio test credentials, the code is always 123456, so the endpoint simply returns that.

app/api/test/get-otp/route.ts

Playwright Configuration for SMS OTP

SMS OTP tests are inherently time-sensitive because of the expiry window. Configure generous action timeouts but keep assertions tight so you catch real failures quickly. Disable the WebOTP API in your browser context to prevent the browser from auto-filling the code before your test types it.

playwright.config.ts
Install Dependencies

3. Scenario: Happy Path OTP Login

The first scenario every SMS OTP integration needs is the basic happy path: submit a valid phone number, receive the code, enter it, and land on the authenticated dashboard. This is your smoke test. If this breaks, nobody can log in. The flow uses Twilio Verify test credentials, so no real SMS is sent. The test phone number +15005550006 always accepts the code 123456.

1

Happy Path OTP Login

Straightforward

Goal

Starting from the login page, submit the test phone number, retrieve the OTP code via the test API, enter the code into the split input fields, and confirm the session is created.

Preconditions

  • App running at APP_BASE_URL
  • Twilio Verify test credentials configured
  • Test phone number +15005550006 registered

Playwright Implementation

sms-otp-login.spec.ts

What to Assert Beyond the UI

Happy Path Assertions

  • Dashboard heading is visible after redirect
  • Session cookie exists and has correct domain
  • Phone number is displayed in the user profile section
  • No error banners or warning toasts are visible

Happy Path OTP Login: Playwright vs Assrt

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

test('SMS OTP login: happy path', async ({ page, request }) => {
  const testPhone = process.env.TEST_PHONE_SUCCESS!;
  await page.goto('/login');
  await page.getByLabel(/phone/i).fill(testPhone);
  await page.getByRole('button', { name: /send code/i }).click();

  await expect(
    page.getByText(/enter.*code/i)
  ).toBeVisible({ timeout: 10_000 });

  const otpRes = await request.get(
    `${process.env.APP_BASE_URL}/api/test/get-otp?phone=${encodeURIComponent(testPhone)}`
  );
  const { code } = await otpRes.json();

  const otpInputs = page.locator('input[data-otp]');
  for (let i = 0; i < 6; i++) {
    await otpInputs.nth(i).fill(code[i]);
  }

  await page.getByRole('button', { name: /verify/i }).click();
  await page.waitForURL(/\/dashboard/, { timeout: 15_000 });

  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
  const cookies = await page.context().cookies();
  expect(cookies.find(c => c.name === 'session')).toBeDefined();
});
55% fewer lines

4. Scenario: Phone Number Validation and Formatting

Before the OTP code ever gets sent, the phone number input itself is a minefield. Twilio Verify requires E.164 format (for example,+14155552671), but users type phone numbers in dozens of formats: with parentheses, dashes, spaces, leading zeros, and missing country codes. Your frontend likely uses a phone input library like react-phone-number-input or intl-tel-input that formats and validates before submission. Testing this validation layer ensures bad numbers never reach the Twilio API.

2

Phone Number Validation

Moderate

Playwright Implementation

sms-otp-phone-validation.spec.ts

Assrt Equivalent

# scenarios/sms-otp-phone-validation.assrt
describe: Phone number validation rejects invalid formats

given:
  - I am on the login page

steps:
  - fill the phone field with "12345"
  - click "Send Code"

expect:
  - an error about invalid phone number is visible
  - I am still on /login

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: OTP Input Autofill and Split Field Entry

Most modern OTP UIs use split input fields: six individual <input>elements, one per digit, with JavaScript that auto-focuses the next field after each keystroke and supports paste across all fields. This pattern creates three distinct testing challenges. First, Playwright's fill() method sets the value directly without triggering keystroke events, which means the auto-advance logic may not fire. Second, pasting the full code into the first field relies on a paste event handler that distributes digits across all fields. Third, the autocomplete="one-time-code" attribute triggers the WebOTP API in Chromium, which can fill the code before your test does.

3

OTP Split Field Entry

Complex

Playwright Implementation

sms-otp-split-input.spec.ts

Split OTP Input: Playwright vs Assrt

const code = '123456';
const otpInputs = page.locator(
  'input[aria-label*="digit"], input[data-otp]'
);

// Press each key individually to trigger auto-advance
await otpInputs.first().focus();
for (const digit of code) {
  await page.keyboard.press(digit);
}

// Verify all fields are filled
for (let i = 0; i < 6; i++) {
  await expect(otpInputs.nth(i)).toHaveValue(code[i]);
}

// Submit
await page.getByRole('button', { name: /verify/i }).click();
await page.waitForURL(/\/dashboard/);
57% fewer lines

6. Scenario: OTP Expiry Countdown and Resend Logic

Every OTP has a time-to-live. Twilio Verify defaults to a 10-minute expiry window, but many applications set shorter durations (30 to 120 seconds) for security. The UI typically shows a countdown timer and a “Resend Code” button that activates only after the countdown reaches zero or after a minimum wait period. Testing this requires manipulating time in your test: you need to verify the countdown displays correctly, confirm the resend button becomes active at the right moment, and assert that an expired code is rejected.

Playwright provides page.clock for controlling time-dependent UI without actually waiting 30 seconds. You can fast-forward the clock to test the expiry state instantly.

4

OTP Expiry and Resend Logic

Complex

Playwright Implementation

sms-otp-expiry.spec.ts

Assrt Equivalent

# scenarios/sms-otp-expiry-resend.assrt
describe: OTP expiry countdown and resend logic

given:
  - I am on the OTP verification screen
  - the OTP was sent 30 seconds ago

steps:
  - wait for the countdown timer to reach zero
  - verify the "Resend" button becomes clickable
  - click "Resend"

expect:
  - a new OTP is sent
  - the countdown timer resets
  - the "Resend" button is disabled again

7. Scenario: Rate Limit Handling and Retry Backoff

Twilio Verify enforces rate limits at multiple levels: per phone number (5 send attempts per phone in a rolling window), per service (configurable), and at the account level. When your test suite runs in parallel or reruns frequently during development, hitting these limits is nearly inevitable. Your application should surface rate limit errors gracefully rather than showing a generic 500 error. Testing this behavior means intentionally triggering the limit and verifying the UI response.

The cleanest approach is to intercept the API response from your backend using Playwright's page.route() to simulate a 429 rate limit response without actually hitting Twilio.

5

Rate Limit Handling

Complex

Playwright Implementation

sms-otp-rate-limit.spec.ts

Rate Limit Handling: Playwright vs Assrt

await page.route('**/api/send-otp', (route) => {
  route.fulfill({
    status: 429,
    contentType: 'application/json',
    body: JSON.stringify({
      error: 'rate_limit_exceeded',
      message: 'Too many attempts.',
      retryAfter: 60,
    }),
  });
});

await page.goto('/login');
await page.getByLabel(/phone/i).fill('+15005550006');
await page.getByRole('button', { name: /send code/i }).click();

await expect(
  page.getByText(/too many attempts/i)
).toBeVisible({ timeout: 5_000 });

const sendBtn = page.getByRole('button', { name: /send code/i });
expect(await sendBtn.isDisabled()).toBeTruthy();
53% fewer lines

8. Scenario: Wrong Code and Max Attempts

Users mistype OTP codes. They transpose digits, enter a code from a previous SMS, or type the code from a different service. Your application needs to handle incorrect codes gracefully: show a clear error, allow retries up to a maximum number of attempts (Twilio Verify defaults to 5), and then lock out the verification and require a new code to be sent. Testing this path catches regressions in error messaging and max-attempt enforcement.

6

Wrong Code and Max Attempts

Moderate

Playwright Implementation

sms-otp-wrong-code.spec.ts

Assrt Equivalent

# scenarios/sms-otp-wrong-code.assrt
describe: Wrong OTP code shows error and allows retry

given:
  - I am on the OTP verification screen

steps:
  - enter the code "999999"
  - click "Verify"

expect:
  - an error about incorrect code is visible
  - the OTP fields are cleared
  - I can enter a new code

9. Common Pitfalls That Break SMS OTP Test Suites

After building and maintaining SMS OTP test suites across multiple production applications, these are the pitfalls that cause the most wasted debugging hours. Every item below is sourced from real GitHub issues, Stack Overflow threads, and Twilio community forum reports.

SMS OTP Testing Anti-Patterns

  • Using fill() on split OTP inputs instead of keyboard.press() per digit. The fill() method sets the value property directly, bypassing the input event handlers that auto-advance focus to the next field. Your test fills the first field but the remaining five stay empty.
  • Relying on real SMS delivery in CI. SMS delivery through Twilio is not instant and can take 2 to 15 seconds depending on carrier load. Test flakiness from delivery latency is the number one complaint in Twilio community forums. Use test credentials or a test endpoint instead.
  • Not disabling the WebOTP API in test contexts. Chrome's WebOTP API (autocomplete='one-time-code') can intercept an incoming SMS and auto-fill the OTP input before your test types the code. This causes a race condition where the test types over the auto-filled value.
  • Hardcoding a single test phone number across parallel test workers. Twilio rate limits are per-phone, so running 4 parallel workers all sending to +15005550006 will hit the 5-attempt-per-phone limit within the first run.
  • Ignoring the OTP expiry window in slow CI environments. A test that takes 45 seconds to reach the verify step will fail if the OTP expires in 30 seconds. Configure the expiry window for your test environment to be at least 2x your slowest test execution time.
  • Not clearing the OTP input fields before retry. Some split-input implementations do not auto-clear on error. If your test enters '999999', gets an error, then enters '123456', the fields may still contain '999456' because only the first three digits were overwritten.
  • Testing against production Twilio credentials in staging. Production credentials cost real money per SMS and are subject to stricter rate limits. A runaway test loop can generate a significant Twilio bill and lock out legitimate users.
  • Not testing the country code selector. International users select different country codes, and the phone input must correctly prepend the code before sending to Twilio. A bug here silently sends invalid numbers to the API.

Split Input Focus Management

The most subtle pitfall is focus management in split OTP inputs. Libraries like react-otp-input and input-otp use different strategies for managing focus across the six fields. Some listen for input events, some for keydown, and some use MutationObserver on the value attribute. The safe approach is to always use page.keyboard.press() for each digit, which fires the full event chain (keydown, keypress, input, keyup) and triggers whatever listener the library uses.

Twilio Rate Limit Headers

When Twilio returns a 429, the response includes a Retry-Afterheader with the number of seconds to wait. Your application should parse this and display it to the user. Your test should verify that the displayed wait time matches the header value. A common bug is showing a hardcoded “try again later” message instead of the actual wait duration from Twilio.

SMS OTP Test Suite Run

10. Writing These Scenarios in Plain English with Assrt

Every scenario above involves 25 to 60 lines of Playwright TypeScript. The split OTP input logic alone requires understanding whether the implementation uses input events, keydownevents, or paste handlers. The rate limit scenario requires route interception boilerplate. The expiry test requires clock manipulation. Multiply this by seven scenarios and you have a test file that is tightly coupled to both the OTP library's internal DOM structure and the API response format.

Assrt lets you describe these scenarios in plain English, generates the equivalent Playwright code, and regenerates the selectors and interaction patterns when the underlying OTP library updates its DOM structure. The split-input handling is particularly powerful: Assrt detects whether the OTP input is split or single and uses the correct fill strategy automatically.

scenarios/sms-otp-full-suite.assrt

Assrt compiles each scenario block into the same Playwright TypeScript from the preceding sections, committed to your repo as real tests you can read, run, and debug. When your frontend team swaps react-otp-input for input-otp or migrates from Twilio Verify to a custom OTP service, Assrt detects the selector and API changes, analyzes the new DOM and response shapes, and opens a pull request with the updated test code. Your scenario files remain untouched.

Start with the happy path scenario. Once it is green in CI, add the phone validation tests, then the split-input edge cases, then the expiry and resend flow, then the rate limit simulation, then the wrong-code retry path. In a single afternoon you can achieve complete SMS OTP login coverage that most production applications never manage by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk