How to Test OTP Verification on Web (Complete Guide)

One‑time password (OTP) flows are the gatekeepers for account recovery, multi‑factor authentication, and transaction confirmation on the web. A single flaw—whether it lets an attacker bypass the code,

June 25, 2026 · 18 min read · How-To Guides

Why OTP Verification Demands Rigorous Testing

One‑time password (OTP) flows are the gatekeepers for account recovery, multi‑factor authentication, and transaction confirmation on the web. A single flaw—whether it lets an attacker bypass the code, traps a legitimate user in an endless resend loop, or leaks the secret in a network trace—can undermine the security guarantees of the whole application and expose users to fraud or account takeover. Because OTP verification touches UI, backend services, third‑party gateways, and often involves asynchronous timing, bugs that survive unit tests frequently surface only under real‑world load, specific browser quirks, or when a user behaves in an unexpected way. A disciplined testing strategy that combines manual exploration, automated scripts, and persona‑driven autonomous analysis is therefore essential to ship reliable OTP experiences.

Core Concepts: How OTP Works on the Web

Understanding the moving parts helps you design tests that hit the right seams.

Typical Flow

  1. Trigger – User clicks “Send code” or submits a form that requires OTP.
  2. Request – Frontend calls an API endpoint (e.g., POST /auth/send-otp) with an identifier (email, phone, user‑id).
  3. Generation – Backend creates a cryptographically random numeric or alphanumeric token, stores it (often hashed) with an expiry timestamp, and associates it with the identifier.
  4. Delivery – The token is dispatched via SMS, email, push notification, or a third‑party authenticator API.
  5. Presentation – User receives the code and enters it into a verification field.
  6. Validation – Frontend posts the code to /auth/verify-otp; backend compares the supplied value (after hashing) with the stored secret, checks expiry, and either marks the identifier as verified or returns an error.
  7. Post‑verification – On success, the user is redirected, a session token is issued, or a protected action is allowed.

Key Attributes to Test

AttributeWhat to VerifyTypical Failure Modes
Entropy & LengthToken must be unpredictable and of configured length (usually 4‑8 digits).Predictable patterns, reuse of old tokens, insufficient entropy.
ExpiryToken becomes invalid after a defined window (e.g., 5 minutes).No expiry check, expiry too short/long, clock‑skew issues.
Rate LimitingOnly N send attempts per identifier per time window.Missing limits enable SMS/email flooding; overly strict limits lock out legitimate users.
Resend LogicResend button works, respects cooldown, does not reset expiry incorrectly.Resend sends a new token but keeps old expiry, or allows immediate resend.
Input HandlingLeading/trailing whitespace, non‑numeric chars, paste, autocomplete are handled.Trimming bugs, rejection of valid paste, acceptance of malformed input.
Error MessagingClear, user‑friendly messages for wrong code, expired code, too many attempts.Generic “error”, leakage of whether a code exists (user enumeration).
SecurityToken never appears in URLs, logs, or client‑side storage; transmission over HTTPS only.Token in query string, debug logs, localStorage, or HTTP.
AccessibilityFields labeled correctly, announce errors, support keyboard navigation, sufficient contrast.Missing aria-label, reliance on color alone, focus traps.
InternationalizationWorks with locale‑specific phone formats, language‑specific error messages, RTL layouts.Hard‑coded US‑centric regex, missing translation keys.

Test Matrix for OTP Verification

A systematic matrix ensures you cover happy paths, error paths, edge cases, and non‑functional aspects. Below is a comprehensive table you can adapt to your own feature flags or delivery channels.

Table 1: Functional Test Matrix

IDCategorySub‑caseDescriptionExpected ResultNotes for Automation
F1Happy PathValid code entryUser receives correct OTP, enters it within expiry.Verification succeeds, user proceeds.Use a mock OTP service that returns a known token.
F2Happy PathCode entry after resendUser clicks “Resend”, receives new OTP, enters it.New OTP validates, old OTP rejected.Track token IDs to ensure old token is invalidated.
F3Input ValidationLeading/trailing spacesUser pastes “ 123456 ” (spaces).Spaces trimmed, code accepted.Send raw string with spaces via type() or fill().
F4Input ValidationNon‑numeric charactersUser types “12a456”.Field rejects or strips non‑digits, shows error.Verify UI feedback and API payload.
F5Input ValidationEmpty submitUser clicks Verify with empty field.Inline error: “Code required”.Check that no network request is sent.
F6Rate Limiting – SendExceed max sendsUser hits “Send code” >5 times in 30 s.Further sends blocked, UI shows cooldown.Mock backend to return 429 after threshold.
F7Rate Limiting – VerifyExceed max verifiesUser attempts wrong code >5 times.Account temporarily locked or CAPTCHA shown.Simulate failures and assert lockout state.
F8ExpiryCode used after expiryWait 6 min after receipt, then submit.Error: “Code expired”.Use clock.mock or backend time‑shift.
F9ExpiryCode used before expirySubmit within 30 s.Success.Standard happy path.
F10Resend TimingImmediate resend blockedClick Resend before cooldown (e.g., 10 s).Button disabled, tooltip shows wait time.Verify UI state and API call suppression.
F11Resend Token ChangeNew token differsAfter resend, entered old token fails.Old token rejected, new token accepted.Compare token hashes in backend logs.
F12Delivery FailureSMS gateway downBackend simulates provider error.UI shows “Unable to send code, try again”.Mock HTTP 503 from gateway.
F13Delivery FailureEmail bounceInvalid email address supplied.Validation error before sending.Ensure client‑side email regex runs.
F14Security – Token LeakageToken in URLUser manually edits URL to include ?code=123456.Server ignores or rejects token from query string.Attempt GET with token in query, assert 400/401.
F15Security – Token in LogsBackend logs OTPDebug level logs capture token.No token appears in production logs.Scan log output for token pattern.
F16Accessibility – LabelMissing labelInspect DOM for without associated or aria-label.Fix: add proper label.Use axe-core or manual inspection.
F17Accessibility – Error AnnouncementScreen reader does not read errorSubmit invalid code, listen with NVDA/Jaws.Error message announced.Test with screen‑reader emulator.
F18Internationalization – Phone FormatNon‑US numberInput +44 7911 123456.Accepted, OTP sent via appropriate gateway.Ensure backend normalizes E.164.
F19Internationalization – RTL LayoutLanguage switched to ArabicUI mirrors, input field aligns right.Layout correct, no overlap.Test with lang="ar" and dir=rtl.
F20Cross‑BrowserSafari autofillSafari suggests OTP from messages.Autofill works, code fills correctly.Test on real device or BrowserStack.
F21Cross‑BrowserFirefox password manager interferenceManager offers to save OTP as password.No interference, OTP field not saved.Verify autocomplete="one-time-code" attribute.
F22Edge Case – Rapid TapsUser double‑taps SendTwo rapid requests.Only one request processed, second ignored or queued.Spy on network calls.
F23Edge Case – Navigation AwayUser leaves page after sending, returns later.OTP still valid if within expiry.Verification works on return.Use page.goBack() then forward.
F24Edge Case – Tab DuplicationUser opens same flow in two tabs.Each tab has independent OTP state.Sending in one tab does not affect the other.Verify isolation via separate storage keys.
F25Edge Case – OfflineUser loses network after receiving OTP.Can still submit when back online.Submission succeeds if within expiry.Simulate offline with page.context().setOffline(true).

Table 2: Non‑Functional & Persona‑Driven Test Matrix

PersonaGoalSpecific ChecksTools / Techniques
Curious ExplorerTry every UI element, including hidden or debug links.Verify no hidden OTP bypass, no exposed API keys in dev tools.Manual exploratory + DOM search.
Impatient UserSpam Send/Resend, attempt rapid verification.Rate limiting, UI feedback, no crash under rapid fire.Automated burst scripts (e.g., for i in {1..20}; do curl …; done).
Novice UserRelies on placeholders, expects clear instructions.Placeholder text, tooltip, error messages in plain language.Usability testing, readability scores.
Adversarial TesterAttempts to brute‑force, replay, or leak tokens.Token entropy, replay protection, lockout, no timing side‑channels.Burp Suite Intruder, OWASP ZAP, custom scripts.
Elderly UserMay have reduced vision, motor control.Large touch targets, high contrast, support for zoom, no time‑pressured auto‑advance.Accessibility audits (axe, WCAG contrast checker).
Accessibility UserUses screen reader, keyboard only.All controls reachable via Tab, ARIA labels, live regions for errors.Keyboard navigation + screen‑reader (NVDA, VoiceOver).
Power UserWants to paste from password manager, use autocomplete.autocomplete="one-time-code" works, paste accepted, no extra steps.Test with Bitwarden, LastPass, native OS autofill.
Security‑Focused AuditorChecks for leakage, insecure storage, improper HTTP verbs.No token in URL, localStorage, cookies; HTTPS only; proper SameSite flags.Network inspection, static analysis, CSP review.

Manual Testing Approach: Step‑by‑Step

A disciplined manual session catches nuances that scripts may overlook, especially around UX, timing, and environmental factors.

  1. Prepare a Clean Test Environment
  1. Trigger the Flow
  1. Intercept the OTP Delivery
  1. Validate Input Handling
  1. Test Expiry and Resend
  1. Attempt Error Paths
  1. Check Security Properties
  1. Accessibility Spot‑Check
  1. Cross‑Browser / Device Checks
  1. Document Findings

Automated Testing Strategies

Automation provides repeatability and regression safety. Below are patterns that work well for web OTP verification, grouped by layer.

Unit / Service Layer


// Example: Jest test for expiry
jest.useFakeTimers();
const { generateOTP, verifyOTP } = require('./otpService');

test('OTP expires after 5 minutes', () => {
  const { token, expiresAt } = generateOTP('user@example.com');
  expect(verifyOTP('user@example.com', token)).toBe(true);
  jest.advanceTimersByTime(5 * 60 * 1000 + 1000); // just past expiry
  expect(verifyOTP('user@example.com', token)).toBe(false);
});

API Contract Tests

UI‑Level Automation

Choose a framework that can handle asynchronous waits and iframes (common for OTP widgets). Playwright and Cypress are popular; the snippets below illustrate each.

#### Playwright (TypeScript)


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

test.describe('OTP verification flow', () => {
  test('happy path with mocked email OTP', async ({ page }) => {
    // 1. Navigate to login page that offers OTP
    await page.goto('/login');
    await page.fill('#email', 'test@example.com');
    await page.click('button:has-text("Send code")');

    // 2. Intercept the outbound email request (using a mock SMTP server)
    const [email] = await Promise.all([
      page.waitForEvent('response', resp =>
        resp.url().includes('/api/send-email') && resp.request().method() === 'POST'
      ),
      page.waitForTimeout(500) // give the mock time to capture
    ]);
    const emailBody = await email.json();
    const otpCode = emailBody.otp; // assume mock returns the OTP in JSON

    // 3. Fill OTP and submit
    await page.fill('#otp-input', otpCode);
    await page.click('button:has-text("Verify")');

    // 4. Assert success redirect or toast
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('.success-toast')).toContainText('Verified');
  });

  test('expired OTP shows error', async ({ page }) => {
    await page.goto('/login');
    await page.fill('#email', 'test@example.com');
    await page.click('button:has-text("Send code")');

    // Grab OTP from mock, then fast‑forward time
    const [resp] = await Promise.all([
      page.waitForResponse(r => r.url().includes('/api/send-email')),
      page.waitForTimeout(200)
    ]);
    const otp = (await resp.json()).otp;

    // Simulate 6 minute delay
    await page.evaluate(() => new Promise(r => setTimeout(r, 6 * 60 * 1000)));
    await page.fill('#otp-input', otp);
    await page.click('button:has-text("Verify")');

    await expect(page.locator('.error-message')).toHaveText(
      /code.*expired/i
    );
  });
});

#### Cypress (JavaScript)


describe('OTP verification', () => {
  const mockOTP = '123456';

  beforeEach(() => {
    // Stub the outbound email/SMS call
    cy.intercept('POST', '/api/send-otp', {
      statusCode: 200,
      body: { message: 'Sent', otp: mockOTP } // mock returns OTP for testability
    }).as('sendOTP');

    // Stub the verification endpoint
    cy.intercept('POST', '/api/verify-otp', (req) => {
      if (req.body.otp === mockOTP) {
        req.reply({ statusCode: 200, body: { success: true } });
      } else {
        req.reply({ statusCode: 400, body: { error: 'Invalid code' } });
      }
    }).as('verifyOTP');
  });

  it('accepts correct OTP', () => {
    cy.visit('/login');
    cy.get('#email').type('user@test.com{enter}');
    cy.wait('@sendOTP');
    cy.get('#otp-input').type(mockOTP);
    cy.get('button:contains("Verify")').click();
    cy.wait('@verifyOTP');
    cy.url().should('include', '/dashboard');
    cy.get('.success').should('contain', 'Verified');
  });

  it('shows error for wrong OTP', () => {
    cy.visit('/login');
    cy.get('#email').type('user@test.com{enter}');
    cy.wait('@sendOTP');
    cy.get('#otp-input').type('654321');
    cy.get('button:contains("Verify")').click();
    cy.wait('@verifyOTP');
    cy.get('.error-message').should('contain', 'Invalid');
  });
});

Leveraging Test‑Specific OTP Services

Integrating with CI

Leveraging Autonomous, Persona‑Driven Exploration (SUSA)

While scripted tests verify known scenarios, autonomous exploration can surface issues that only appear when a user behaves in an unexpected manner or when the system state deviates from the happy path. SUSA (the autonomous QA platform) fits naturally explores an app by simulating a variety of user personas, each with a distinct behavior model, and learns from each run to avoid revisiting dead ends.

How SUSA Approaches OTP Flows

  1. Persona Profiles – The platform ships with built‑in personas:
  1. Exploration Loop – For each persona, SUSA:
  1. Learning & Deduplication – SUSA builds a graph of visited screens and edge cases. If a particular path (e.g., “Send → immediate Resend → verification”) has already been exercised with a given persona, subsequent runs skip it unless a new condition (like a different locale or a modified header) is introduced. This prevents exponential blow‑up while still achieving high coverage.
  1. Bug Reporting – When a deviation from expected behavior is detected (crash, ANR‑like long task, WCAG violation, security issue such as token leakage in a URL, or a flow that never reaches a terminal state), SUSA emits a detailed report containing:

Practical Benefits for OTP Testing

Running SUSA Against a Web OTP Flow


# Install the agent (once)
pip install susatest-agent

# Point it at your staging site; provide a mock OTP service URL via env var
OTP_MOCK_URL=https://mock-otp.example.com susatest-agent \
  --target https://staging.myapp.com/login \
  --personas curious impatient adversarial accessibility \
  --output ./susareport \
  --max-depth 6 \
  --timeout 300

The agent will explore the login/OTP flow, generate a report (susareport/summary.json), and highlight any OTP‑specific anomalies it encounters. You can then feed those findings back into your manual test checklist or automated test suite.

> Note: The SUSA mention is kept brief and instructional, focusing on how its autonomous, persona‑driven approach complements traditional testing rather than promoting a sales pitch.

Common Production‑Only Bugs and How to Catch Them

Some defects only manifest under real‑world traffic, specific browser quirks, or atypical user behavior. Below are frequent production‑only OTP issues and tactics to surface them early.

BugWhy It Appears Only in ProdDetection Technique
Token leakage via Referer headerWhen the OTP verification page redirects to a third‑party analytics endpoint, the full URL (including query‑string OTP) may be sent as Referer. In dev, analytics may be disabled or mocked.Enable network logging with header inspection; use a proxy (Burp, OWASP ZAP) to capture outbound requests and verify no OTP appears in Referer.
Browser autofill conflictsChrome’s “Save password” may mistakenly store the OTP as a password and later auto‑fill it into login fields, causing confusion. This depends on the exact autocomplete attribute and form hierarchy, which can differ between dev and prod builds due to minification or CMS injection.Test with real Chrome profiles (not incognito) and observe the password manager’s behavior; use autocomplete="one-time-code" and autocomplete="new-password" on appropriate fields.
Race condition between token generation and deliveryUnder high load, the backend may generate a token, store it, but the SMS gateway experiences a delay; the user attempts verification before the token persists, leading to false‑negative.Simulate latency with a mock gateway that delays response by a configurable interval; run concurrent verification attempts using tools like k6 or artillery.
Cache poisoning of OTP endpointA misconfigured CDN may cache the /auth/send-otp response (including any embedded token) and serve it to subsequent users. Caching is often disabled in dev but enabled in prod via Varnish or Cloudflare.Send a request with a unique identifier, then immediately repeat the request with a different identifier; verify the response bodies differ and contain no OTP from the first request.
Locale‑specific regex rejecting valid international numbersThe production validation may rely on a library that only runs when the server’s locale is set to en_US. In dev, the default locale may be different, masking the bug.Deploy the app with various locale environment variables (LANG=fr_FR.UTF-8, LC_ALL=ja_JP.UTF-8) and run the OTP flow with international phone numbers.
Accessibility overlay interfering with OTP fieldSome sites inject third‑party accessibility widgets that add tabindex=-1 or cover inputs with a div, making the field unreachable via keyboard. These widgets are often loaded asynchronously and may not appear in staging if the widget script is blocked by ad‑filters.Run the flow with a screen‑reader and keyboard-only navigation; use the axe extension to detect any elements that obscure focusable items.
Session fixation after OTP verificationAfter successful OTP verification, the app may retain the pre‑verification session ID, allowing an attacker who forced a session ID to hijack the account. Dev tests often use fresh sessions per test, missing this nuance.Capture the session cookie before sending OTP, after verification, and confirm it has changed (or that the server issued a new session ID with proper Secure; HttpOnly; SameSite=Strict flags).
Push notification OTP not displayed on locked screenOn Android/iOS web apps installed as PWAs, the push notification may be hidden when the device is locked, causing the user to think the OTP never arrived. This depends on the notification priority and platform settings, which are not exercised in desktop‑centric test suites.Use a device farm or emulator; lock the screen after triggering the push; check whether the notification appears on the lock screen (Android: heads‑up, iOS: banner).

Mitigation Checklist for Production‑Only Risks

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free