Fix Playwright Login Screenshots When an Indian Website Sends an OTP
Save a completed OTP login as Playwright storage state, reuse it safely, and capture screenshots only after confirming the authenticated page is ready.
Direct answer: Complete the OTP challenge once with an authorized test account, wait until the site shows a clear signed-in state, save Playwright’s browser state, and load that state in screenshot tests. If a restored browser still shows the login page, check whether the site uses sessionStorage, whether the saved session expired, and whether your test waited for the authenticated page before capturing.
The title mentions an Indian website, but the site and OTP channel are unspecified. There is no evidence here for a special India-specific Playwright behavior or a particular cause such as SMS delays, CAPTCHA, or regional blocking. Treat this as a general authenticated-browser testing workflow and follow the website owner’s approved test process.
1. Complete OTP login once and save authenticated state
Use a dedicated test account and an approved way to complete its OTP challenge. A setup project can perform the login, wait for a meaningful post-login condition, and save the browser state for later tests. Playwright’s authentication guide documents this setup-project pattern and use of saved state in test projects. Playwright authentication guide.
Install Playwright Test if needed:
npm init -y
npm install --save-dev @playwright/test
npx playwright install
Create playwright/.auth and add it to .gitignore. The state file can contain cookies and headers that impersonate the account, so protect it like a credential and do not commit it.
# .gitignore
playwright/.auth/
Example setup test, tests/auth.setup.ts. Replace the example URL, selectors, and destination condition with the actual site’s login and authenticated page. Complete the OTP using the test account’s authorized process; this example intentionally does not automate OTP retrieval or bypass the challenge.
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('sign in and save browser state', async ({ page }) => {
await page.goto('https://example.test/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL ?? '');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? '');
await page.getByRole('button', { name: 'Sign in' }).click();
// If the site asks for an OTP, complete it through the approved test flow.
// For a manual setup, enter the code in the open browser, then resume.
await expect(page).toHaveURL(/\/dashboard(?:\?|$)/, { timeout: 120_000 });
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Set TEST_EMAIL and TEST_PASSWORD through your local or CI secret manager, not source code. If the login is manually completed during setup, keep the browser open while doing so; then let the post-login URL or visible-element assertion pass before saving state. If your setup needs to start the browser in a visible mode for human completion, configure the setup project accordingly.
Configure the setup project and dependent tests in playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium-authenticated',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
testIgnore: /auth\.setup\.ts/,
},
],
});
Then write the screenshot test against a page condition that proves you reached the intended area:
import { test, expect } from '@playwright/test';
test('captures the signed-in dashboard', async ({ page }) => {
await page.goto('https://example.test/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
await page.screenshot({ path: 'artifacts/dashboard.png', fullPage: true });
});
Run the setup and dependent project with npx playwright test. The first run establishes state; later runs reuse it, until the site expires or invalidates the session.
2. Choose the right authentication setup
| Approach | Use it when | Trade-off |
|---|---|---|
| UI login in a setup project | The site’s supported path is its normal login UI and OTP. | Closely exercises the real sign-in flow, but setup may require a human or approved test OTP flow. |
| API-based authentication | The application provides an appropriate, authorized test authentication API. | Can avoid repeating UI login; ensure the resulting cookies or tokens are installed in the browser context as the application expects. |
| One saved state for the suite | Tests are read-only or do not interfere through shared account data. | Simple, but parallel tests can conflict if they mutate the same account’s server-side state. |
| Separate state per worker | Parallel tests change server-side data. | Requires separate authorized accounts or isolated test data; avoids workers interfering with each other. |
Playwright’s guide describes using one account per parallel worker when tests modify shared server-side state. It also notes that authentication state can expire, so refresh it through the approved login flow when necessary. Authentication and worker-account guidance.
3. Diagnose why the screenshot still shows login or OTP
Confirm state was saved after login completed
Saving immediately after clicking “Sign in” can capture the pre-authentication state, an intermediate redirect, or a page that has not finished establishing the session. Save only after a stable destination URL and an authenticated UI element are present. Do not rely on a fixed sleep alone.
Check what storage the app uses
Playwright storage state covers cookies and localStorage; current documentation also describes IndexedDB and passkeys. Some applications keep authentication data in sessionStorage, which is scoped to an origin and is not included in ordinary storageState reuse. If the app relies on sessionStorage, use the separate save-and-restore technique documented by Playwright and apply it only to the correct origin. Playwright authentication guide and storageState API reference.
Check session expiry and account policy
A state file is a snapshot, not a permanent login. The server may expire or revoke it, require another OTP, or invalidate sessions after account changes. Re-run the authorized setup flow and save a fresh file. Avoid printing cookie or token contents in CI logs.
Assert the final page, not just navigation
A successful URL change does not necessarily mean the application finished rendering the target content. Check a role, heading, account control, or other stable authenticated marker before capture. If the page is intentionally an OTP challenge, assert the challenge itself when that is the target.
4. Make screenshot output stable
For a one-off screenshot, wait for the authenticated page’s meaningful condition before calling page.screenshot(). For visual regression tests, use Playwright’s screenshot assertion: it waits for two consecutive screenshots to match before comparison. The documentation says screenshot assertions are for the Playwright test runner. Visual comparisons and screenshot assertions.
import { test, expect } from '@playwright/test';
test('dashboard visual state', async ({ page }) => {
await page.goto('https://example.test/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page).toHaveScreenshot('dashboard.png', { fullPage: true });
});
Use a fixed viewport and the same browser configuration between baseline and comparison runs. If content changes dynamically, control test data or mask genuinely variable regions using the screenshot assertion options. Capture after the app’s relevant images and data are ready; arbitrary long delays slow the suite without proving readiness.
5. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot shows the login page | State was saved before sign-in completed, missing state file, or session expired. | Assert the post-login URL and a signed-in element before saving; verify the configured path and regenerate expired state. |
| OTP page appears after restore | The saved session is no longer accepted, or the app requires storage not present in the state file. | Refresh state through the authorized setup flow; inspect whether the app uses sessionStorage or another documented mechanism. |
| State file exists but tests are unauthenticated | The test project did not load that file, path differs from the setup path, or the login uses another origin. | Check the resolved path and project’s storageState setting; ensure state is captured for the same site origin. |
| Setup times out waiting for dashboard | Wrong URL or locator, incomplete OTP step, or application has not reached the expected state. | Inspect the page during setup, choose a condition matching the actual site, and complete the approved OTP flow before saving. |
| One parallel test logs another out or changes its data | Workers share one account and mutate common server state. | Use a separate account/state per worker or isolate test data as described in Playwright’s authentication guide. |
| Visual comparison is flaky | Capture occurs before content settles, or time-dependent/dynamic content differs. | Wait for a meaningful ready condition, stabilize test data and viewport, and mask only unavoidable dynamic regions. |
6. Performance, reliability, and cost
Saving state once avoids repeating the login and OTP flow in every test, which can reduce setup time and dependence on interactive sign-in. It does not eliminate session expiry: make state refresh part of the test environment’s setup when the application requires it. Parallel runs improve throughput only when accounts and mutable data are isolated well enough to avoid interference.
Protect state files in local and CI environments, exclude them from source control, and scope test accounts to the minimum data and permissions needed. Never hard-code OTP secrets or credentials in a test. Use the site owner’s test configuration, human-assisted login, or an approved authentication API; do not defeat access controls.
Or skip the browser setup
If you need a screenshot of a publicly accessible page rather than a page behind a private OTP login, ScreenshotNeo can capture it with one GET request. It cannot sign in to a private account using this screenshot call, so keep the Playwright workflow for authenticated pages. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots, and 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000. Sign up for free and get 1,000 screenshots a month with no card.
FAQ
Does saving Playwright state mean I can skip OTP forever?
No. It reuses the session state until the site expires or invalidates it, at which point the approved login setup must run again.
Can Playwright use the same state for every browser?
State reuse depends on the browser context and application behavior. Keep the browser configuration consistent for screenshot comparisons and verify authentication in each project configuration you use.
Can ScreenshotNeo capture the private page after OTP?
The provided ScreenshotNeo screenshot call captures a URL; it is not an authenticated Playwright context. Use saved Playwright state for private pages that require your account session.


