ScreenshotNeo

BlogHow-to

Fix a Website Screenshot That Shows the Logged-Out Page After Redirect

If a screenshot lands on a login page, reuse valid Playwright storage state and verify the final URL and signed-in UI before capture.

By the ScreenshotNeo team4 October 20269 min read

If your automated screenshot shows a login page after a redirect, the browser context probably did not load a valid signed-in session, the session expired, or the capture happened before the login and redirects finished. In Playwright, save browser storage state after a completed login, load it into the screenshot context, then verify both the final URL and a signed-in UI element before taking the screenshot.

A screenshot captures the page currently rendered; it does not confirm that the page is authenticated. Diagnose the destination and session first, then capture only after the expected page is visible.

1. Check where the browser actually ended up

Record the final URL after navigation and compare it with the signed-in destination you expected. Playwright navigation returns the response associated with the final redirect, and client-side navigation may continue after the initial document response. A successful click on “Sign in” alone is not proof that authentication is complete: the site may set cookies through several redirects or render its signed-in interface afterward.

Use two signals at the capture gate:

  • Expected URL: confirms the browser did not finish on a login or callback URL.
  • Authenticated UI cue: confirms signed-in content rendered, such as an account menu or a heading unique to authenticated users.

Choose a stable, site-specific cue. A generic navigation item may also appear for logged-out visitors and is weak evidence.

2. Save Playwright authentication state after login

For repeat screenshot runs, log in in a setup flow, wait for the completed redirect or authenticated UI, and save the browser context state. Reuse that state in the context that takes the screenshot. Playwright storage state can include cookies and local storage; IndexedDB can be included when the application stores authentication there. The file is sensitive because it can contain credentials that impersonate the account. Keep it out of source control and restrict access.

Install and configure

This Node.js example uses Playwright Test. Install the packages, set the site-specific environment variables, and create a setup test that performs the login once. Replace the example selectors and URLs with those used by your site.

npm install --save-dev @playwright/test
npx playwright install chromium

Create playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
      dependencies: ['setup'],
      testIgnore: /auth\.setup\.ts/,
    },
  ],
});

Set APP_ORIGIN, APP_LOGIN_URL, APP_USER, and APP_PASSWORD in your environment or CI secret store. Do not put real credentials in the test file.

Create tests/auth.setup.ts:

import { test as setup, expect } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';

const stateDir = path.join(process.cwd(), 'playwright/.auth');
const stateFile = path.join(stateDir, 'user.json');

setup('sign in and save browser state', async ({ page }) => {
  const loginUrl = process.env.APP_LOGIN_URL;
  const username = process.env.APP_USER;
  const password = process.env.APP_PASSWORD;

  if (!loginUrl || !username || !password) {
    throw new Error('Set APP_LOGIN_URL, APP_USER, and APP_PASSWORD');
  }

  await page.goto(loginUrl);
  await page.getByLabel('Email').fill(username);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Use the real post-login destination and a stable signed-in cue.
  await expect(page).toHaveURL(/\/dashboard(?:[/?#]|$)/, { timeout: 30_000 });
  await expect(page.getByRole('button', { name: /account|profile/i })).toBeVisible();

  await mkdir(stateDir, { recursive: true });
  await page.context().storageState({ path: stateFile, indexedDB: true });
});

The URL pattern, accessible labels, and account button are examples. Match your application’s actual login form and authenticated destination. If the app does not use IndexedDB for authentication, the option is unnecessary; including it is useful when its login state is stored there.

Load the saved state and capture only after checks pass

Create tests/capture.spec.ts:

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

const stateFile = path.join(process.cwd(), 'playwright/.auth/user.json');
const appOrigin = process.env.APP_ORIGIN;

test('capture the authenticated dashboard', async ({ browser }) => {
  if (!appOrigin) throw new Error('Set APP_ORIGIN');

  const context = await browser.newContext({ storageState: stateFile });
  const page = await context.newPage();

  try {
    await page.goto(new URL('/dashboard', appOrigin).toString(), {
      waitUntil: 'domcontentloaded',
    });

    await expect(page).toHaveURL(/\/dashboard(?:[/?#]|$)/, { timeout: 30_000 });
    await expect(page.getByRole('button', { name: /account|profile/i })).toBeVisible();
    await page.screenshot({ path: 'artifacts/dashboard.png', fullPage: true });
  } finally {
    await context.close();
  }
});

Run the setup and dependent capture project with npx playwright test. The project dependency runs setup first. A separate browser context is created for the capture, so the test explicitly loads the saved state; browser contexts are isolated and do not share another context’s cookies automatically.

3. Choose the right state and wait strategy

Approach Use it when Trade-off
Log in during every run The session must be fresh or the application’s login flow is part of what you need to test. Repeats the login work and can make runs slower or more dependent on the identity provider.
Save and reuse storageState You need repeat captures of protected pages and can refresh state when it expires. State becomes stale and must be protected as a credential.
URL assertion You need to catch a redirect to a login or callback route. A correct URL alone does not prove signed-in content rendered.
Authenticated element assertion You can identify a stable element only shown to signed-in users. It depends on choosing a reliable site-specific cue.

For critical captures, use both URL and UI assertions. Use separate saved state files and projects for distinct user roles; do not reuse one role’s state when another role’s permissions affect the page.

Wait for conditions, not an arbitrary long delay. Playwright’s locator assertions wait for the expected state up to their timeout. A fixed sleep may hide a slow redirect on one run while wasting time on every fast run.

4. Trace a redirect that still ends at login

If the stored state is loaded but the browser still lands on login, record the request chain. Redirected requests link to the request that preceded them; the chain can reveal whether the application redirected to a callback, rejected a session, or sent the browser to the identity provider.

page.on('request', request => {
  if (request.isNavigationRequest()) {
    const previous = request.redirectedFrom();
    console.log({
      url: request.url(),
      method: request.method(),
      redirectedFrom: previous?.url() ?? null,
    });
  }
});

Attach this listener before page.goto. Treat logs as potentially sensitive: URLs can contain state parameters or other values you should not publish. Avoid logging cookies, authorization headers, or the saved state contents.

Compare the hops with the expected flow and check which transition first changes the destination. If the server sends the browser to login immediately, suspect missing, expired, or wrong-domain cookies, an incorrect account/role, or a state file that was never loaded. If the URL remains on the application while the page changes to login, inspect client-side routing and the app’s own authentication checks.

5. Troubleshooting common causes

Symptom Likely cause Fix
Capture consistently shows the login page The capture context was created without the intended state, or the state belongs to a different domain or account. Pass the saved state when creating the exact context used for capture. Regenerate it by signing in at the target site and verify the account and destination before saving.
It worked before but now redirects to login Stored state expired, was revoked, or the account requires reauthentication. Rerun the setup flow and replace the state file. Do not assume saved state lasts indefinitely.
Setup saves state but the next run is logged out State was saved immediately after the click, before the final redirect or authentication cookie was set. Wait for the final URL and an authenticated UI cue before calling storageState.
URL looks right but screenshot is still logged out The application renders logged-out content at that route, or client-side authentication has not completed. Assert a signed-in-only element before capture; investigate the client-side transition if it never appears.
Screenshot is intermittent Timing depends on redirects, client-side routing, or delayed session initialization. Replace fixed sleeps with URL and locator assertions. Set a practical timeout and inspect the redirect sequence when it expires.
Cookie appears in state but is not accepted The cookie may have a domain, path, secure, or same-site scope that does not match the target request, or the server may have invalidated it. Authenticate on the correct site and origin, then inspect the resulting navigation and regenerate state. Do not manually broaden cookie scope as a shortcut.
App uses local storage or IndexedDB The authentication data is not only in cookies, or the required browser storage was omitted. Save storage state after login; include IndexedDB where the application uses it, and confirm the target Playwright version supports the option.
Works locally but fails in CI CI may lack the state file, use different secrets, run against another origin, or have a different setup order. Generate state in the CI setup job, pass it securely to the capture job, check the origin and project dependency, and do not commit the state file.

6. Reliability, runtime, and cost considerations

Reusing state avoids repeating interactive login for every capture, but it adds a refresh step when sessions expire. A robust pipeline should fail clearly when either the expected URL or signed-in UI cue is missing, rather than silently saving a login screenshot as if it were valid. Keep setup and capture failures distinguishable in logs, and refresh state through the normal login flow.

Browser startup, login, redirect hops, page rendering, and full-page screenshots all add time. Use a fresh context with the saved state for isolation; avoid a large fixed wait when a specific condition is available. Full-page captures can take longer and consume more memory than viewport captures, especially on long pages.

Playwright itself is browser automation software. Your infrastructure cost depends on where the browser runs and how often you capture; the supplied documentation does not establish a universal runtime or cost figure. Protecting saved state is also an operational requirement: restrict artifact access, avoid exposing it in logs, and exclude it from source control.

Or skip the browser setup

If you need a clean public-page screenshot and do not need to authenticate to a private account, ScreenshotNeo provides a screenshot API and MCP server. One GET request captures a URL as PNG, JPEG, WebP, or PDF. Its consent handling accepts cookie banners and removes supported consent platforms, newsletter popups, and chat widgets before capture; those steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. The MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf.

For example, this cURL request saves a WebP screenshot:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Equivalent Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Equivalent Node.js:

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 import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

See the ScreenshotNeo API documentation for request options. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card.

FAQ

Does Playwright keep me logged in automatically between runs?

No. A new browser context is isolated. Save storage state after login and explicitly load it into the context used by the later run.

Can I use saved state for multiple user roles?

Yes. Save separate state files for each role and configure the relevant tests to use the matching state.

Does ScreenshotNeo capture authenticated pages?

The provided ScreenshotNeo facts describe URL-based captures and support for custom headers, cookies, user agents, and Authorization. They do not describe a browser storage-state import, so Playwright storage-state workflows remain the relevant method when a site requires an interactive signed-in session.

What should I share when asking for help?

Share the relevant navigation code, whether and how state is loaded, the expected and final URLs, and a redacted redirect sequence. Do not share credentials, cookies, authorization values, or the storage-state file.

Sources