Authentication Testing Guide
How to Test TOTP 2FA Flow with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing TOTP two-factor authentication with Playwright. Covers QR code secret extraction, time-based code generation with the otpauth library, time skew handling, backup code verification, and full recovery flow testing in CI.
“According to Microsoft, multi-factor authentication blocks over 99.9% of account compromise attacks, yet only 28% of users had MFA enabled as of 2023. TOTP authenticator apps are the most widely deployed MFA method across enterprise applications.”
Microsoft Digital Defense Report 2023
TOTP 2FA Enrollment and Challenge Flow
1. Why Testing TOTP 2FA Is Harder Than It Looks
TOTP (Time-based One-Time Password) is defined by RFC 6238 and is the standard behind Google Authenticator, Authy, 1Password, and every other authenticator app. The algorithm takes a shared secret and the current Unix timestamp, divides the timestamp into 30-second windows, and produces a six-digit code that changes every half minute. That time dependency is the root cause of most TOTP testing failures.
The first structural challenge is secret extraction. When a user enrolls in TOTP, the application displays a QR code that encodes a URI like otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp. In a real authenticator app, the camera scans the QR code. In a Playwright test, you cannot scan images. You need to either parse the QR code image with a library like jsQR, extract the secret from a “Can't scan?” manual entry link, or intercept the API response that delivers the secret to the frontend.
The second challenge is time sensitivity. A TOTP code is valid for exactly one 30-second window (most servers accept the previous and next window too, giving a 90-second effective window). If your test generates a code at second 29 of a window and the server validates it at second 1 of the next window, the code may be rejected. Clock skew between your CI runner and the application server compounds this problem.
The third challenge is state accumulation. Once a user enrolls in TOTP, every subsequent login requires a valid code. If your test suite enrolls a user in TOTP during one test but does not persist the secret, every downstream test that logs in as that user will fail. Backup codes add another layer: they are single-use, so a test that consumes a backup code changes the state for all subsequent tests.
The fourth challenge is recovery flow complexity. When a user loses their TOTP device, the recovery path typically involves email verification, admin approval, or backup codes. Each of those paths has its own test surface. Finally, many applications implement “remember this device” cookies that skip the TOTP challenge for 30 days, which your test needs to explicitly clear or respect depending on the scenario.
TOTP 2FA Enrollment Flow
Login
Credentials accepted
MFA Setup
Server generates secret
QR Code
Encode otpauth:// URI
User Scans
Or manual entry
Enter Code
First valid TOTP
Enrolled
Secret stored
TOTP 2FA Challenge Flow (Subsequent Logins)
Login
Credentials accepted
2FA Prompt
Enter TOTP code
Validate
Server checks code
Window Check
Current +/- 1 period
Authenticated
Session created
A comprehensive TOTP test suite must cover enrollment, challenge, invalid codes, time skew, backup codes, and recovery. The sections below walk through each scenario with runnable Playwright TypeScript code you can paste into a real project.
2. Setting Up a Reliable Test Environment
Before writing any TOTP test scenarios, you need three things in place: a test user management system, the otpauth library for generating valid TOTP codes in your tests, and a helper module that wraps secret storage and code generation. Without these, every test becomes a copy-paste mess of TOTP boilerplate.
TOTP Test Environment Setup Checklist
- Install otpauth library: npm install otpauth
- Install jsqr for QR code parsing: npm install jsqr
- Create a test helper module for TOTP code generation
- Configure test user creation with MFA disabled by default
- Set up environment variables for test credentials
- Ensure CI runner clock is synchronized via NTP
- Disable 'remember this device' cookies in test environment
- Configure server to accept previous + next TOTP windows
Environment Variables
TOTP Helper Module
The otpauth library implements RFC 6238 and RFC 4226 in JavaScript. It can generate valid TOTP codes from a base32-encoded secret, which is exactly what authenticator apps do internally. Wrap it in a helper so your test files stay clean.
QR Code Parsing Helper
Some applications only expose the TOTP secret through a QR code image, with no manual entry fallback. In those cases, you need to decode the QR code programmatically. The jsQR library can parse QR code image data in Node.js. Combine it with Playwright's screenshot capability to capture and decode the QR code element.
Playwright Configuration for TOTP Tests
TOTP tests are inherently serial because enrollment must happen before challenge tests. Use Playwright projects with dependencies to enforce ordering. Set a generous action timeout because TOTP validation involves multiple round trips.
3. Scenario: TOTP Enrollment from QR Code
TOTP enrollment is the most complex scenario because it requires extracting the shared secret from the application. The application generates a random secret, encodes it as a QR code (and often provides a manual entry fallback), and waits for the user to enter a valid code to confirm they have stored the secret in their authenticator app. Your test needs to intercept that secret, generate a valid code, and submit it to complete enrollment.
There are three approaches to secret extraction, listed from most reliable to least. First, intercept the API response that delivers the secret to the frontend using Playwright's route interception. Second, click the “Can't scan the QR code?” link to reveal the raw secret as text. Third, decode the QR code image using jsQR. The first approach is the most stable because it does not depend on UI element selectors.
TOTP Enrollment via API Interception
ModerateGoal
Log in with a user that has no TOTP enrolled, intercept the enrollment API response to extract the secret, generate a valid TOTP code, and complete enrollment.
Preconditions
- App running at
APP_BASE_URL - Test user exists with verified email, no MFA enrolled
- MFA policy is set to “required” for the test user
Playwright Implementation
Alternative: Extract Secret from Manual Entry Link
Alternative: Decode the QR Code Image
TOTP Enrollment: Playwright vs Assrt
import { test, expect } from '@playwright/test';
import { generateTOTPCode, extractSecretFromOTPAuthURI } from '../helpers/totp';
test('TOTP enrollment via API interception', async ({ page }) => {
let capturedSecret = '';
await page.route('**/api/mfa/totp/enroll', async (route) => {
const response = await route.fetch();
const body = await response.json();
capturedSecret = body.secret;
await route.fulfill({ response });
});
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/set up two-factor/i)).toBeVisible();
await expect(page.locator('[data-testid="totp-qr-code"]')).toBeVisible();
const code = generateTOTPCode(capturedSecret);
await page.getByLabel(/verification code/i).fill(code);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByText(/two-factor.*enabled/i)).toBeVisible();
});4. Scenario: TOTP Challenge on Login
Once a user has enrolled in TOTP, every subsequent login requires entering a valid code. This is the bread-and-butter 2FA test: log in with credentials, get prompted for a TOTP code, generate one from the stored secret, submit it, and land on the authenticated page. The key requirement is that the secret persisted during enrollment is available to this test.
The most common failure in this scenario is timing. If you generate the TOTP code and then take several seconds to fill the form and submit, the 30-second window may expire. The solution is to generate the code as late as possible, immediately before filling the input. For extra safety, check how many seconds remain in the current window and wait for the next window if fewer than five seconds remain.
TOTP Challenge on Subsequent Login
StraightforwardPlaywright Implementation
TOTP Challenge: Playwright vs Assrt
import { test, expect } from '@playwright/test';
import { generateTOTPCode, secondsRemainingInWindow } from '../helpers/totp';
test('TOTP challenge: valid code', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/enter.*verification code/i)).toBeVisible();
const remaining = secondsRemainingInWindow();
if (remaining < 5) {
await page.waitForTimeout((remaining + 1) * 1000);
}
const code = generateTOTPCode(totpSecret);
await page.getByLabel(/verification code/i).fill(code);
await page.getByRole('button', { name: /verify/i }).click();
await page.waitForURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});5. Scenario: Invalid TOTP Code Rejection
Testing the unhappy path is just as important as the happy path. When a user enters an incorrect TOTP code, the application must show a clear error message and not grant access. This scenario also covers rate limiting: most implementations lock the account after three to five consecutive failed TOTP attempts.
The tricky part is generating a guaranteed-invalid code. Simply using “000000” works for most implementations, but some servers reject obviously invalid input differently from a valid-format but wrong code. The safest approach is to generate a valid code and then increment the last digit modulo 10 to produce a code that is exactly one digit off.
Invalid TOTP Code and Rate Limiting
StraightforwardPlaywright Implementation
6. Scenario: Handling Time Skew Between Client and Server
TOTP codes are generated from the current Unix timestamp divided into 30-second periods. If the clock on your CI runner is even slightly ahead of or behind the application server, codes that should be valid may be rejected. This is especially common in containerized CI environments where the system clock drifts over time, or when testing against remote staging servers in different data centers.
Most TOTP server implementations accept codes from the current period plus or minus one period (a 90-second effective window). Some strict implementations only accept the current period (a 30-second window). Your tests need to account for both cases. The safest strategy is to generate the code at the start of a new 30-second window, giving you maximum time for the round trip.
Time Skew Resilient TOTP Validation
ComplexPlaywright Implementation
Detecting Clock Skew in CI
Before running TOTP tests, validate that the CI runner clock is reasonably synchronized. A drift of more than 30 seconds will cause all TOTP tests to fail unpredictably.
7. Scenario: Backup Code Generation and Usage
Most applications that implement TOTP also provide backup codes (sometimes called recovery codes) during enrollment. These are typically eight to twelve single-use codes that the user can enter instead of a TOTP code if they lose access to their authenticator app. Testing backup codes requires three things: capturing the codes during enrollment, using one to authenticate, and verifying that a used code cannot be reused.
The most common implementation displays backup codes immediately after TOTP enrollment succeeds, on a dedicated screen. Some applications offer a “Download” or “Copy” button. Your test needs to capture these codes and store them for subsequent tests. The single-use nature of backup codes means your test suite must track which codes have been consumed.
Backup Code Generation and Single-Use Validation
ModeratePlaywright Implementation
Backup Code Auth: Playwright vs Assrt
test('backup codes: authenticate', async ({ page }) => {
const codeToUse = backupCodes[0];
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/enter.*verification code/i)).toBeVisible();
await page.getByRole('link', { name: /use.*backup/i }).click();
await page.getByLabel(/backup code/i).fill(codeToUse);
await page.getByRole('button', { name: /verify/i }).click();
await page.waitForURL(/\/dashboard/);
});8. Scenario: Account Recovery When TOTP Device Is Lost
The recovery flow is the most complex TOTP scenario to test because it typically spans multiple channels. When a user loses their authenticator device and has no backup codes remaining, they need an alternative way to regain access. Common implementations include email-based recovery links, SMS fallback codes, admin-initiated MFA resets, and support ticket workflows.
For end-to-end testing, the email-based recovery flow is the most automatable. The user requests a recovery link, receives an email, clicks the link, and either disables TOTP or re-enrolls with a new device. Testing this requires email interception (Mailosaur, a catch-all domain, or the application's API) and careful sequencing.
Email-Based TOTP Recovery
ComplexPlaywright Implementation
Alternative: Admin API Recovery
If your application exposes an admin API for MFA management, you can reset TOTP enrollment programmatically. This is faster and more reliable than email-based recovery for CI environments.
9. Common Pitfalls That Break TOTP Test Suites
TOTP test suites are some of the most fragile in any end-to-end test project. The combination of time sensitivity, shared state, and multi-step flows creates failure modes that do not exist in simpler tests. The following pitfalls come from real GitHub issues, Stack Overflow threads, and production incident reports.
TOTP Testing Anti-Patterns to Avoid
- Generating the TOTP code too early: code expires before server validates it. Generate immediately before form submission.
- Hardcoding a TOTP secret in the test file: if the server rotates secrets, every test breaks silently.
- Not cleaning up MFA enrollment between test runs: the user is already enrolled, so the enrollment test fails.
- Using Date.now() mocks without restoring them: breaks every subsequent test that depends on real time.
- Running TOTP tests in parallel: two tests generating codes for the same user at the same time will conflict.
- Ignoring the 'remember this device' cookie: test passes locally but the TOTP challenge never appears in CI.
- Not accounting for server-side rate limiting: five failed attempts locks the account for all subsequent tests.
- Using SHA-256 or SHA-512 in the test when the server uses SHA-1: different algorithms produce different codes.
Pitfall: Edge-of-Window Code Generation
The most reported TOTP testing failure on GitHub issue trackers is intermittent authentication failures caused by generating a TOTP code near the end of a 30-second window. The code is valid when generated, but by the time the test fills the form, clicks submit, and the server validates it, the window has rolled over. The fix is the secondsRemainingInWindow() guard shown in Section 4.
Pitfall: MFA State Leaking Between Tests
When test A enrolls a user in TOTP and test B expects that user to have no MFA, test B will fail because the enrollment persists in the database. The solution is to reset the user's MFA state in a beforeEach hook using the admin API. Never rely on test execution order to maintain state. Each test should set up its own preconditions explicitly.
Pitfall: Algorithm Mismatch
The TOTP spec (RFC 6238) defaults to SHA-1 with 6 digits and a 30-second period, and this is what Google Authenticator and most server implementations use. However, some applications configure SHA-256 or SHA-512 for additional security. If your test helper uses SHA-1 but the server expects SHA-256, every generated code will be wrong. Always check the algorithm parameter in the otpauth:// URI during enrollment.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above is 30 to 70 lines of Playwright TypeScript, and the TOTP-specific helper code adds another 60 lines on top. The total test file for comprehensive TOTP coverage easily exceeds 400 lines. When the application changes its MFA enrollment flow (renaming a button, restructuring the QR code page, or changing the API response format), you need to update selectors across multiple test files. Assrt lets you describe the scenarios in plain English and handles the selector resolution automatically.
The TOTP enrollment scenario from Section 3 is a perfect example. In raw Playwright, you need to know the exact route pattern for API interception, the data-testid for the QR code element, the label pattern for the verification input, and the button text for submission. In Assrt, you describe the intent and the framework resolves each step against the current DOM.
Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections. The TOTP helper functions (code generation, QR decoding, secret extraction) are resolved automatically when Assrt detects a TOTP-related step. When the application renames “Verify” to “Confirm” or changes the recovery flow, Assrt detects the failure, analyzes the new DOM, and opens a pull request with updated selectors. Your scenario files stay untouched.
Start with the enrollment scenario. Once it is green in your CI, add the challenge test, then invalid code rejection, then backup codes, then recovery. In a single afternoon you can have complete TOTP 2FA coverage that would otherwise take days of manual Playwright scripting and ongoing maintenance.
Related Guides
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.