Browser Session Persistence and MFA Automation
Persist Playwright sessions safely, restore every auth store, and automate MFA with test authenticators without weakening production security.

To keep a browser logged in between automated runs, save the authenticated browser state and load it into a new isolated context. In Playwright, storageState is the portable approach for cookies and web storage; a persistent browser profile is the approach that survives browser restarts. MFA should remain enabled: use virtual WebAuthn credentials or a dedicated test OTP secret rather than bypassing production controls.
Authentication is rarely stored in one place. Depending on the application, you may need cookies, localStorage, IndexedDB, origin-private file system data, sessionStorage, or WebAuthn credentials. Start by identifying the stores used after login, then choose the smallest persistence boundary that covers them.
1. Choose a persistence strategy
| Approach | Survives browser restart | Portable across workers or machines | Best use | Main risk |
|---|---|---|---|---|
| In-memory context | No | No | One test or a short fixture | Every run must log in |
storageState |
State file survives; browser process does not | Yes | Shared setup, CI, parallel workers | File contains live credentials |
| Persistent profile | Yes | Usually no | Local debugging and workflows that need a real profile | Profile locking, state leakage and difficult isolation |
| Manual session export | Depends on implementation | Sometimes | Special stores such as sessionStorage | Stale or incomplete state |
Playwright describes authenticated state as potentially including cookies, local storage, IndexedDB and passkeys. Its authentication guide also warns that an auth state file can contain cookies and headers that impersonate the account. Treat every state file as a secret. Read the Playwright authentication guidance and the storageState API reference.

2. Create a reusable Playwright authentication state
Install Playwright
npm init playwright@latest
# Select JavaScript or TypeScript when prompted
npm install
Log in once in a setup project
Create tests/auth.setup.js. The example uses a test account and writes the state outside source-controlled test files.
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('https://app.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();
// Complete MFA through the dedicated test flow here.
await expect(page).toHaveURL(/dashboard/);
await fs.promises.mkdir('playwright/.auth', { recursive: true });
await page.context().storageState({
path: authFile,
indexedDB: true
});
});
The indexedDB option matters when an application stores its access token there. If the app uses only cookies and localStorage, the default snapshot is sufficient. Do not assume that a successful login means every required store was captured.
Configure tests to load the state
// playwright.config.js
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.js/
},
{
name: 'chromium',
use: {
browserName: 'chromium',
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
}
]
});
// tests/account.spec.js
import { test, expect } from '@playwright/test';
test('opens the authenticated account page', async ({ page }) => {
await page.goto('https://app.example.test/account');
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
});
Use a separate state file for each identity when tests can mutate server data. In parallel execution, a state file per worker prevents one test from revoking, changing or refreshing another worker’s session.
3. Use a persistent profile when the browser itself must survive
A persistent context stores a Chromium profile on disk, including browser-managed data that a state snapshot may not represent. It is useful for local debugging, extension-dependent flows and reproducing a problem after closing the browser. It is less suitable for parallel CI because one profile cannot safely be opened by several browser processes.
import { chromium } from '@playwright/test';
const context = await chromium.launchPersistentContext(
'./playwright/.profiles/debug-user',
{
headless: false,
viewport: { width: 1440, height: 900 }
}
);
const page = await context.newPage();
await page.goto('https://app.example.test');
// The profile remains on disk after context.close().
await context.close();
Keep persistent profiles private and dedicate one profile to one test identity. Never point automation at a developer’s everyday browser profile. Profile data can include passwords, cookies, extensions and browsing history.
4. Persist sessionStorage only when the app requires it
sessionStorage is scoped to an origin and normally lasts only for a page session. Playwright does not provide a direct storageState field for it. Serialize it after login, then install it with an initialization script before application code runs.
// Save after the login flow
const sessionStorage = await page.evaluate(() => {
const out = {};
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i);
out[key] = window.sessionStorage.getItem(key);
}
return out;
});
await fs.promises.writeFile(
'playwright/.auth/session-storage.json',
JSON.stringify(sessionStorage)
);
// Restore before navigating to the app
const saved = JSON.parse(
await fs.promises.readFile('playwright/.auth/session-storage.json', 'utf8')
);
await context.addInitScript((state) => {
for (const [key, value] of Object.entries(state)) {
window.sessionStorage.setItem(key, value);
}
}, saved);
Restore only keys that are authentication inputs. Copying the whole object can restore stale wizard steps, feature flags or one-time workflow data. Verify the origin and schema before injection.
5. Automate MFA without disabling it
WebAuthn and passkeys
For a WebAuthn test, create a virtual authenticator for a dedicated test account, register it through the normal enrollment page, and keep the credential inside the isolated test environment. Playwright can expose virtual WebAuthn credentials in a controlled context. The browser context documentation notes that credential snapshots include private keys and that restoring them installs a virtual authenticator; real hardware authenticators do not operate in that restored context. See Playwright’s browser context APIs and the Edge virtual authenticator documentation.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext();
const cdp = await context.newCDPSession(await context.newPage());
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true
}
});
const page = await context.pages()[0];
await page.goto('https://app.example.test/security');
// Register the passkey through the normal UI.
await page.getByRole('button', { name: 'Add passkey' }).click();
// Keep the authenticator and its state in this test fixture only.
console.log(authenticatorId);
WebAuthn credentials are origin-bound public-key credentials. Prefer them where phishing resistance matters; OWASP recommends FIDO2/WebAuthn because it binds authentication to the legitimate origin. The W3C WebAuthn specification describes the challenge-response protocol and authenticator consent requirements.
TOTP and other one-time passwords
Store the TOTP seed in a secret manager available only to the test worker. Generate the current code at runtime and submit it through the normal verification form. Do not place the seed or generated code in fixtures, screenshots, CI logs or error messages.
import { authenticator } from 'otplib';
const code = authenticator.generate(process.env.TEST_TOTP_SECRET);
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
Your tests should verify a short expiration time, single use, replay rejection, strict attempt limits, rate limiting, lockout behavior and safe logging. OWASP’s Multifactor Authentication Cheat Sheet and Web Security Testing Guide cover these checks. Use a dedicated account; never copy production MFA secrets into a test fixture or turn MFA off in production.
6. Protect and rotate authentication state
- Add
playwright/.authand profile directories to.gitignore. - Restrict file permissions so only the test user can read state files.
- Encrypt backups and CI artifacts; avoid uploading state files as ordinary build artifacts.
- Use one account and state file per environment and worker where isolation is required.
- Regenerate state after password, session-policy or MFA changes and after any suspected exposure.
- Redact cookies, authorization headers, OTP values and token payloads from logs.
# .gitignore
playwright/.auth/
playwright/.profiles/
7. Diagnose missing login state
| Symptom | Likely cause | Fix |
|---|---|---|
| Redirected to login | Cookie domain, path or secure flag does not match the test URL | Create state while logged into the same origin and inspect cookies with context.cookies(). |
| Works locally, fails in CI | State file is absent, expired or unavailable to the worker | Run the setup project in CI, use a workspace-local path, and fail fast if the file is missing. |
| Token appears missing | Token is in IndexedDB or sessionStorage | Enable indexedDB: true and explicitly serialize sessionStorage. |
| Parallel tests log each other out | Workers share one mutable account | Use per-worker accounts or state files and isolate server-side test data. |
| WebAuthn says credential not found | Origin or RP ID changed, or a real authenticator was expected | Register a virtual credential on the exact test origin and restore it in the same controlled context. |
| OTP intermittently fails | Clock skew, code expiry or duplicate submission | Synchronize worker time, generate immediately before submission, allow one retry only where policy permits, and test replay rejection separately. |
| State works once then expires | Short server session TTL or refresh-token rotation | Refresh through the supported flow, regenerate state per run, or use a test policy with a documented lifetime. |
| Tests expose credentials | Auth JSON, traces or screenshots were published | Delete artifacts, revoke the account sessions, rotate secrets and add artifact redaction. |
8. Performance, reliability and cost considerations
A one-time setup login reduces repeated MFA work, but a shared state file can become a bottleneck when the server rotates refresh tokens. Prefer immutable state per worker for parallel suites. Persistent profiles avoid repeated setup locally but are slower to provision, harder to clean and more likely to carry accidental state. Storage snapshots are faster to distribute, but only if they include every store your app uses.
Keep authentication setup close to the tests that consume it. Detect expiry with a lightweight authenticated request before starting a large suite. Retry navigation and network calls only when the operation is safe and idempotent; never blindly retry a payment, account mutation or MFA submission. Record which identity and environment a worker uses without recording its secrets.
9. Or skip the browser setup
If your goal is a clean screenshot of an authenticated or public page rather than interactive browser testing, ScreenshotNeo provides a single GET request to return PNG, JPEG, WebP or PDF. It accepts custom headers, cookies, user agents and Authorization values, so you can pass the session inputs your application requires without maintaining a browser profile. See the ScreenshotNeo API documentation for all options.

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}`);
Before capture, cookie and consent banners, newsletter popups and chat widgets are removed. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and whether it was billed. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. You get 1,000 screenshots each month free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
10. Practical implementation checklist
- Map the app’s cookies, localStorage, IndexedDB, OPFS, sessionStorage and WebAuthn usage.
- Use
storageStatefor portable, isolated contexts. - Use a persistent profile only when browser restart persistence is required.
- Capture IndexedDB explicitly when tokens live there.
- Inject only the required sessionStorage keys before page code runs.
- Automate MFA with virtual WebAuthn credentials or secret-managed test OTPs.
- Test expiry, replay, rate limits, lockout and recovery paths.
- Keep state files out of Git, logs, traces and ordinary CI artifacts.
- Use separate identities for parallel workers when server-side state can conflict.
FAQ
Does storageState keep a browser logged in after closing it?
It saves state for creating later contexts; it does not preserve a running browser process. Use a persistent context when the browser profile itself must survive restarts.
Can I restore a real security key from a state file?
No. A controlled virtual WebAuthn credential can be restored for tests. Real hardware authenticators require their normal user-presence flow.
Why does a saved cookie not authenticate my app?
The app may require a token in IndexedDB or sessionStorage, or the cookie may not match the request origin, path or security policy. Inspect all stores used after login.
Should I reuse one MFA secret for every test?
No. Use dedicated test identities and keep each seed in a secret manager. Separate identities reduce lockouts and make failures attributable.
Can ScreenshotNeo replace an end-to-end MFA test?
No. It is useful when you need a rendered screenshot or PDF and can provide the required request credentials. Interactive enrollment, challenge handling and security assertions still belong in browser tests.


