ScreenshotNeo

BlogHow-to

How to capture screenshots of authenticated pages with a rotating CSRF token

Capture authenticated pages reliably by logging in through a real browser context and letting the application manage its rotating CSRF token.

By the ScreenshotNeo team4 October 202610 min read

To capture an authenticated page when its CSRF token changes, log in through the site’s normal browser flow, wait until login is complete, then navigate to the protected page and capture it in the same authenticated browser context. Let the application obtain and submit its current token when it makes requests. Do not extract a token once and reuse it as a permanent credential.

With Playwright, you can either log in for each capture or save browser state after login and load it into a later run. Saved state can avoid repeated logins, but it expires, may not include sessionStorage, and must be treated as a secret. This guide uses Playwright with JavaScript; it also includes Python, cURL, and Node.js options for the ScreenshotNeo API.

1. Why a rotating CSRF token changes the workflow

CSRF protection helps a server distinguish an authorized request from one induced by another site. In the synchronizer token pattern, the server checks that a token submitted with a request is present and valid for the user’s session. A site may issue one token per session or rotate tokens per request. A token taken from an earlier page can therefore be stale when a later request is submitted.

A screenshot navigation may only render a page and never submit a state-changing request. However, page scripts, clicks, forms, and background requests can interact with CSRF protection. The robust approach is to let the site’s own code obtain and send the current token as part of its normal flow. This is an implementation recommendation based on OWASP’s token lifecycle guidance; the exact behavior depends on the application.

OWASP advises that a CSRF token must not be leaked in server logs or in a URL. Keep tokens out of query strings, command-line arguments, screenshots, debug output, and persistent files unless the application’s design specifically requires protected state storage.

2. Capture a page after logging in with Playwright

This example performs a fresh login, waits for a stable sign-in signal, opens a protected page, and saves a full-page screenshot. Replace the URLs, selectors, and credentials with values for an application you are authorized to access. The example assumes the site has a conventional username and password form; adapt the login steps for SSO, MFA, or other authentication flows.

Install Playwright

npm init -y
npm install playwright
npx playwright install chromium

Runnable JavaScript script

// capture-authenticated.js
const { chromium } = require('playwright');

async function main() {
  const baseURL = process.env.APP_BASE_URL;
  const username = process.env.APP_USERNAME;
  const password = process.env.APP_PASSWORD;
  if (!baseURL || !username || !password) {
    throw new Error('Set APP_BASE_URL, APP_USERNAME, and APP_PASSWORD');
  }

  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext();
  const page = await context.newPage();

  try {
    await page.goto(new URL('/login', baseURL).href, { waitUntil: 'domcontentloaded' });
    await page.getByLabel('Email').fill(username);
    await page.getByLabel('Password').fill(password);
    await page.getByRole('button', { name: /sign in|log in/i }).click();

    // Prefer an application-specific success signal over a fixed sleep.
    await page.waitForURL(url => !url.pathname.endsWith('/login'), { timeout: 30000 });
    await page.getByTestId('signed-in-user').waitFor({ state: 'visible', timeout: 30000 });

    await page.goto(new URL('/account/overview', baseURL).href, {
      waitUntil: 'domcontentloaded',
    });
    await page.getByTestId('account-overview').waitFor({ state: 'visible', timeout: 30000 });
    await page.screenshot({ path: 'account-overview.png', fullPage: true });
  } finally {
    await context.close();
    await browser.close();
  }
}

main().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

Run it with credentials supplied through the environment rather than committed in the script:

APP_BASE_URL='https://app.example.com' \
APP_USERNAME='user@example.com' \
APP_PASSWORD='replace-with-a-secret' \
node capture-authenticated.js

Use selectors that match the application. Labels, roles, and test IDs are generally more stable than styling classes. If the login flow stays on the same URL, remove or change the URL condition and rely on a reliable signed-in UI element instead.

3. Reuse authenticated browser state

For repeated captures, Playwright can save supported browser state after a successful login and load it into a new context later. This avoids repeating the interactive login when the saved session remains valid.

Save state after login

Add this after the script has confirmed login succeeded:

const fs = require('node:fs/promises');
await fs.mkdir('playwright/.auth', { recursive: true });
await context.storageState({ path: 'playwright/.auth/user.json', indexedDB: true });

Protect the file and exclude it from version control:

printf '\nplaywright/.auth/\n' >> .gitignore

Playwright’s authentication guidance explains that saved state can include cookies, local storage, and, when requested, IndexedDB. It does not include sessionStorage by default; if the application depends on sessionStorage, save and restore that separately using application-appropriate code. A state file may contain cookies and headers capable of impersonating the account, so handle it like a password. See Playwright authentication documentation.

Load state for a later capture

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext({
    storageState: 'playwright/.auth/user.json',
  });
  const page = await context.newPage();

  try {
    await page.goto('https://app.example.com/account/overview', {
      waitUntil: 'domcontentloaded',
    });
    await page.getByTestId('account-overview').waitFor({ state: 'visible', timeout: 30000 });
    await page.screenshot({ path: 'account-overview.png', fullPage: true });
  } finally {
    await context.close();
    await browser.close();
  }
})().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

Always verify the protected page actually loaded as an authenticated user. A redirect to login can still result in a perfectly valid screenshot of the wrong page. If the application reports an expired session or token validation error, repeat the normal login or revisit the relevant page so the application can refresh its state.

4. Choose fresh login or saved state

Approach Good fit Trade-offs
Fresh login for every capture Short-lived sessions, frequently changing authentication, or jobs where a clean login is important Repeats login work and may require handling MFA or identity-provider redirects
Saved browser state Repeated captures where the session lasts long enough and protected state storage is available State can expire; sessionStorage needs separate handling; the file can enable account impersonation

Choose based on session lifetime, whether the application uses sessionStorage, whether the account is tied to a particular browser or device, whether captures trigger state-changing requests, and whether the state file can be stored securely. Reusing one account’s state is unsuitable when jobs modify server-side data or require separate user identities. Playwright discusses this limitation in its authentication guidance.

5. Keep CSRF handling inside the application flow

  • Use the normal login form or authorized authentication flow to establish the session.
  • Wait for an application-specific sign-in signal before navigating to the target page.
  • Keep the same browser context for login and capture so session cookies and browser storage remain available.
  • Let frontend code obtain the current token from its designed source, such as HTML or JSON, and submit it through the expected form field or request header.
  • Avoid copying a token from one run into source code, a URL, or a long-lived environment variable.
  • If a screenshot workflow clicks controls or submits forms, confirm whether those actions change server state. Prefer read-only navigation for capture jobs.
  • Inspect the application’s own frontend and network behavior in an authorized environment to learn its token refresh and readiness behavior.

OWASP describes tokens that may be generated per session or per request and notes that stale per-request tokens can cause usability problems. The precise refresh mechanism is application-specific; browser automation cannot safely infer it for every site. See the OWASP Cross-Site Request Forgery Prevention Cheat Sheet.

6. Other ways to run the capture

The Playwright example above is the do-it-yourself browser method. If you use Python, the same workflow applies: complete login in a browser context, wait for a signed-in signal, navigate to the protected page, then capture there. The exact authentication selectors and state handling depend on your application.

cURL alone does not run a browser or the application’s frontend code. It can only capture an authenticated page if you already have a supported way to provide the required session credentials and the service can render the page. Do not put a rotating CSRF token in the URL. For sites whose token is acquired by frontend code or rotated during requests, use a browser automation context instead of trying to make a static HTTP request emulate the whole flow.

7. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. It takes a URL in one GET request and returns an image or PDF. For a protected page, the target application still needs to make the page accessible through an authentication method supported by your setup; a screenshot API does not perform your site’s interactive login or solve its CSRF flow. Check the ScreenshotNeo API documentation for supported request options, including custom headers, cookies, and Authorization.

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

Replace the example target URL with a URL your authorized authentication setup can access. ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

8. Troubleshooting

Symptom Likely cause What to do
Screenshot shows the login page Login did not complete, the session expired, or the protected page redirected Wait for a signed-in UI signal; check the final URL and a protected-page element before taking the screenshot; reauthenticate if needed.
CSRF validation fails after login A token was stale, missing, or not paired with the current session Stop reusing the extracted token. Revisit the normal browser flow and let application code obtain and submit current state. Check the app’s token refresh design.
State file works once, then fails Session or token expired, was revoked, or is bound to a browser/device Fall back to fresh login and regenerate state after confirming authentication. Do not assume saved state has an indefinite lifetime.
Cookies restore but the app is logged out The app relies on local storage, IndexedDB, sessionStorage, or additional device checks Save supported browser state; determine whether sessionStorage needs separate restoration; follow the application’s documented authentication path.
Selector timeout during login Labels or test IDs differ, the login form is embedded, or an identity-provider redirect is in progress Inspect the authorized page, use selectors matching the real form, and wait for the relevant redirect or signed-in signal.
Screenshot is blank or incomplete Capture happened before the page rendered, a protected resource failed, or content is lazy-loaded Wait for a stable page element; use an application readiness signal; scroll or interact only as needed to load content, and avoid fixed sleeps when a condition is available.
Login requires MFA or CAPTCHA The site requires an interactive or risk-based authentication step Use an approved authentication path supported by the application, such as a dedicated test account or authorized session setup. Do not bypass access controls.
Authentication state was committed accidentally The auth directory was not excluded from version control Revoke the session if possible, remove the state file from repository history as appropriate, and store future state in a protected secret store.

9. Performance, reliability, and cost

A fresh browser login costs time because it repeats navigation and authentication. Reusing state can shorten repeated runs, but introduces session-expiry and secret-management work. Reliability usually improves when scripts wait for a meaningful URL or UI condition rather than an arbitrary delay, validate that the protected page is actually present, and create a new context per independent job.

Keep captures read-only where possible. A click that submits a form or triggers a mutation can change server-side state and may require a current CSRF token. Parallel captures should not blindly share one account state if the application binds sessions to a browser or if jobs affect one another. For a screenshot-only workflow, the main costs are browser runtime, infrastructure, and the maintenance of the login flow; the precise amount depends on the app and execution environment.

10. Frequently asked questions

How do I take a screenshot of a page after logging in?

Log in in a browser context, confirm the signed-in state, navigate to the protected page, wait for its content, and capture it in that same context.

Why does my CSRF token expire or change?

The application may issue tokens per request or refresh them as part of its session flow. A previously observed token may no longer match the current session state.

Can I save only cookies instead of browser state?

Only if the application’s authentication depends solely on cookies. Some apps also rely on local storage, IndexedDB, sessionStorage, or browser-specific checks.

Can a screenshot service log into any protected site for me?

No. The application must expose a supported authentication path and allow the capture context to access the page. A screenshot request does not replace an interactive login flow or resolve a site’s CSRF requirements.