ScreenshotNeo

BlogHow-to

How to Screenshot a Page That Requires Login with Playwright

Log in with Playwright, save and restore authenticated browser state, then wait for protected content before capturing a reliable screenshot.

By the ScreenshotNeo team4 October 202610 min read

To screenshot a page that requires login with Playwright, authenticate a browser context, verify that login completed, save its storage state, and restore that state in the context that visits the protected page. Wait for the page’s actual content to be ready before capturing it. Treat the saved state as a credential because it can contain cookies or headers that let someone impersonate the account.

This guide uses Playwright’s JavaScript API. The same setup works with Playwright Test: run an authentication setup project first, then configure dependent tests to use the saved state. See the Playwright authentication guide and screenshot documentation.

1. Install Playwright and prepare a private state directory

Install Playwright in your project and install the browser you intend to use:

npm install -D playwright
npx playwright install chromium

Create a directory for local authentication state and exclude it from version control:

mkdir -p playwright/.auth
printf '\nplaywright/.auth/\n' >> .gitignore

Use a dedicated test account with the minimum permissions needed. Do not print passwords, cookies, authorization headers, or the contents of state files to logs.

2. Log in and save storage state

Create save-auth.mjs. Update the environment variable names, login URL, form selectors, and signed-in condition to match your application. This example waits for a post-login URL and then confirms a signed-in element before saving.

import { chromium } from 'playwright';

const loginUrl = process.env.LOGIN_URL;
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
const signedInSelector = process.env.SIGNED_IN_SELECTOR ?? '[data-testid="account-menu"]';
const authFile = 'playwright/.auth/user.json';

if (!loginUrl || !username || !password) {
  throw new Error('Set LOGIN_URL, TEST_USERNAME, and TEST_PASSWORD');
}

const browser = await chromium.launch();
try {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto(loginUrl, { waitUntil: 'domcontentloaded' });
  await page.getByLabel(/email|username/i).fill(username);
  await page.getByLabel(/password/i).fill(password);
  await page.getByRole('button', { name: /sign in|log in/i }).click();

  // Replace this pattern with the final URL expected by your application.
  await page.waitForURL(url => !url.pathname.includes('/login'), { timeout: 30_000 });
  // A URL change alone may not prove that the app has established a session.
  await page.locator(signedInSelector).waitFor({ state: 'visible', timeout: 15_000 });

  await context.storageState({ path: authFile });
  console.log(`Saved authenticated state to ${authFile}`);
} finally {
  await browser.close();
}

Run the script with credentials supplied through your shell or a secrets manager, not hard-coded in the file. For example, in a POSIX shell:

LOGIN_URL='https://example.com/login' \
TEST_USERNAME='test-user@example.com' \
TEST_PASSWORD='replace-me' \
SIGNED_IN_SELECTOR='[data-testid="account-menu"]' \
node save-auth.mjs

The example selectors are placeholders; use selectors that match your site. If the app has an easier supported authentication API, you can authenticate with Playwright’s API request context and save that request context’s storage state instead of driving the login form.

3. Restore state, visit the protected page, and capture it

Create screenshot-protected.mjs. Restoring state into a new context keeps the authenticated visit separate from the login setup. Replace the URL and ready selector with the page and content your application requires.

import { chromium } from 'playwright';

const targetUrl = process.env.TARGET_URL;
const readySelector = process.env.READY_SELECTOR ?? '[data-testid="report-content"]';
const authFile = 'playwright/.auth/user.json';

if (!targetUrl) throw new Error('Set TARGET_URL');

const browser = await chromium.launch();
try {
  const context = await browser.newContext({
    storageState: authFile,
    viewport: { width: 1440, height: 1000 },
  });
  const page = await context.newPage();
  await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });

  // Wait for the application-specific content, not just navigation.
  await page.locator(readySelector).waitFor({ state: 'visible', timeout: 30_000 });
  await page.screenshot({ path: 'protected-page.png', fullPage: true });
  await context.close();
} finally {
  await browser.close();
}

Run it with:

TARGET_URL='https://example.com/account/reports/123' \
READY_SELECTOR='[data-testid="report-content"]' \
node screenshot-protected.mjs

For a quick manual check, inspect the resulting page for the expected signed-in content. A successful navigation is not proof that the protected content loaded: an application may redirect, render a login shell, or show an error inside the page.

4. Use Playwright Test when the screenshot is an assertion

If the goal is visual regression testing, use Playwright Test’s screenshot assertion so the image is compared with a stored baseline. A minimal setup project can create state before the dependent test project runs.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: { browserName: 'chromium', storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto(process.env.LOGIN_URL!);
  await page.getByLabel(/email|username/i).fill(process.env.TEST_USERNAME!);
  await page.getByLabel(/password/i).fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: /sign in|log in/i }).click();
  await expect(page.locator('[data-testid="account-menu"]')).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// tests/protected-page.spec.ts
import { test, expect } from '@playwright/test';

test('protected report renders', async ({ page }) => {
  await page.goto('https://example.com/account/reports/123');
  await expect(page.locator('[data-testid="report-content"]')).toBeVisible();
  await expect(page).toHaveScreenshot('protected-report.png', { fullPage: true });
});

Run the setup and test through Playwright Test, for example with npx playwright test. The setup project is a dependency of the browser project. In UI mode, setup projects do not run by default; rerun authentication setup manually when the saved state expires.

5. Pick an authentication-state strategy

Situation Approach Watch for
One test account, no conflicting changes Reuse one saved state Parallel tests may still conflict if they change shared server-side data.
Parallel tests modify shared data Use a separate account and state per worker Provision accounts and clean up their test data.
Tests cover different permissions Save separate user and admin states Keep each state file separate and grant only required permissions.
One scenario needs two identities at once Create two browser contexts with separate state files Do not try to represent two users in one context.
UI login is slow or awkward and the app supports a suitable auth endpoint Authenticate with Playwright’s API request context, save its state, then use it in a browser context The endpoint must establish browser-compatible state for the same environment.

When server-side data is mutable, isolated accounts are more reliable than sharing one account across parallel workers. A shared browser state does not isolate server-side changes.

6. Know what storage state includes

Playwright’s storage-state flow can preserve cookies and local storage, and supports optional IndexedDB capture in current versions. The authentication guide also covers passkey (WebAuthn) state in supported flows. These capabilities depend on the Playwright version: the API reference marks IndexedDB capture as added in v1.51 and virtual WebAuthn credential capture as added in v1.61. Check the documentation and installed version before depending on them.

Session storage is not included in the usual storage-state snapshot. If your app depends on it, the authentication guide demonstrates capturing it separately and restoring it with context.addInitScript, scoped to the intended origin. Keep that separate data secret too; avoid exposing it in logs or broadly accessible files.

The API reference marks OPFS capture as added in v1.63 and notes that OPFS is not supported in ephemeral WebKit contexts. Browser-specific state and newer storage features can therefore affect whether a saved login transfers to the context you use. Verify on the same browser and environment used for the capture.

7. Make captures repeatable

  • Wait for a page-specific ready element or application signal. Do not use a fixed delay as the only proof that data loaded.
  • Keep the browser, viewport, device scale, account data, and target environment consistent for visual comparisons.
  • Use a stable test account and deterministic data where possible; rotating content, timestamps, and personalized widgets can make images differ.
  • For screenshot assertions, Playwright Test stores PNG snapshots by default. Naming a snapshot with a .webp extension selects WebP, which the docs describe as lossless.
  • Use screenshot assertion styling such as stylePath to hide known volatile elements when appropriate. The screenshot documentation describes this option for styling during comparisons.
  • Choose between a viewport screenshot and fullPage: true based on the question the image should answer. Full-page captures can be taller and may expose lazy-loaded content or sticky-element behavior differently.

8. Troubleshooting

Symptom Likely cause Fix
Protected URL redirects to login State was saved before the login flow completed, the wrong state file was loaded, or the app requires state not included in the snapshot. Wait for a signed-in UI condition before saving; confirm the exact state path; check whether the app relies on session storage, browser-specific state, or an additional authentication step.
Login script times out waiting for a URL The app’s redirect pattern differs from the placeholder, or login completes without changing the URL. Use the actual expected URL pattern or wait for a reliable signed-in element instead.
Login appears complete but the protected page is empty The app shell rendered, but data is still loading or an API request failed. Wait for the actual content selector and inspect the page’s error state and network behavior during diagnosis.
Saved state worked yesterday but fails now Cookies or other credentials expired or were revoked. Delete the expired state and rerun the authentication setup. Reauthenticate manually in Playwright UI mode when needed.
Works in Chromium but not another browser Authentication may be browser-specific, or a storage capability differs by browser/version. Authenticate using the same browser as the capture and check the version-specific storage support.
Tests fail intermittently in parallel Workers share an account and modify the same server-side data. Assign separate accounts and state per worker, or prevent conflicting operations.
Screenshot differs between runs Dynamic data, viewport differences, late content, or animations and volatile regions affect rendering. Use a stable viewport and ready condition; make test data deterministic and hide only known volatile regions in screenshot assertions.
ENOENT when saving state The parent directory does not exist. Create playwright/.auth before running the setup script.

9. Security, reliability, and runtime cost

The state file is a reusable credential. Playwright explicitly warns that it may contain sensitive cookies and headers usable to impersonate the account, and strongly discourages committing it to repositories, including private ones. Add the directory to .gitignore, restrict access in CI, use short-lived or dedicated test accounts, and rotate credentials if state is exposed.

Authentication setup adds browser startup and login work, but saving state avoids repeating the login flow for every page visit. For reliability, refresh expired state deliberately and make setup a dependency of tests that require it. In CI, store secrets in the CI secret store and create the state file during the job rather than checking it in.

Playwright itself has no per-screenshot API charge described here; runtime and infrastructure costs depend on where the browser runs and how often it runs. Full-page images use more time and storage than viewport images, and repeated login flows add load to the application. Keep captures scoped to the needed page and use worker-specific accounts when concurrency changes server data.

Or skip the browser setup

If you need a screenshot without managing a browser login flow, ScreenshotNeo is a website screenshot API and MCP server. One GET request takes a URL and returns an image or PDF. Its parameters include custom headers, cookies, and Authorization for pages where the site accepts a supported credential, but this does not replace an interactive login flow or guarantee access to any particular protected page. 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', res);

Replace the example URL with your target and provide only credentials the target site supports. ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server offers screenshot and page-information tools for AI agents. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. Sign up for 1,000 free screenshots a month, with no card required.

FAQ

Can I reuse the same state file for multiple tests?

Yes, if those tests can safely share the same account and server-side data. Use separate accounts when concurrent tests modify shared data.

Does storage state include session storage?

No. Capture and restore session storage separately when the application requires it, scoped to the correct origin.

Can I take a screenshot without Playwright Test?

Yes. Use the Page API’s page.screenshot() method as in the runnable script above. Use toHaveScreenshot() when you want a visual assertion against a baseline.

Why does the saved state contain secrets?

Cookies and headers can carry the active session. Anyone who can use valid state may be able to act as that test account, so protect and refresh it like a credential.

Primary sources