How to Save a Website Screenshot After Logging In with Headless Chrome
Save and reuse an authenticated browser session with Playwright, then capture a protected page reliably with headless Chrome. Includes runnable code and troubleshooting.
To save a screenshot of a page that requires login, authenticate in a browser context, save its authentication state, load that state into the context used for capture, open the protected page, wait for a meaningful signed-in page signal, and take the screenshot. Headless mode hides the browser window; it does not remove the need for valid cookies or other authentication state.
For repeatable authenticated captures, Playwright is the practical choice because it provides an explicit state save-and-restore workflow. Chrome’s command-line screenshot flags are useful for capturing a page that is already accessible in the chosen browser profile, but the CLI documentation does not provide the same complete login-state recipe. Playwright documents authentication state, and Chrome documents headless mode.
1. Install Playwright and prepare a private auth directory
This example uses JavaScript with Playwright. Install the package and browser, then create a directory for the state file. The file can contain cookies and other data that allow someone to act as the signed-in user, so keep it private and out of version control.
npm init -y
npm install playwright
npx playwright install chromium
mkdir -p playwright/.auth
Add the state file to .gitignore before creating it:
printf '\nplaywright/.auth/\n' >> .gitignore
Use an account and target site you are authorized to access. Replace the example URLs and page selectors throughout this guide with values for your site.
2. Sign in once and save the authenticated state
Create save-auth.mjs. The login form selectors and post-login success condition are site-specific. The example waits for a URL change to the dashboard; a reliable signed-in heading or account control is also suitable.
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto('https://example.com/login', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Email').fill(process.env.SITE_EMAIL ?? '');
await page.getByLabel('Password').fill(process.env.SITE_PASSWORD ?? '');
await page.getByRole('button', { name: 'Sign in' }).click();
// Replace this with the site's actual successful-login signal.
await page.waitForURL('https://example.com/dashboard', { timeout: 30_000 });
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await context.storageState({
path: 'playwright/.auth/user.json',
indexedDB: true,
});
console.log('Saved authenticated browser state.');
} finally {
await browser.close();
}
Run it with credentials supplied through your shell or secret manager, rather than embedding real credentials in source code. For example, in a shell that supports environment assignments:
SITE_EMAIL='you@example.com' SITE_PASSWORD='your-password' node save-auth.mjs
Do not use this example as a way to bypass MFA, access controls, or a site’s terms. If the authorized login flow requires MFA or an identity-provider redirect, complete the flow as the site requires and save state only after a clear successful-login signal.
3. Load the state and capture the protected page
Create capture.mjs. The capture context restores the saved cookies and supported storage, navigates to the protected page, checks that the expected signed-in content appears, and saves a full-page PNG.
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
colorScheme: 'light',
});
const page = await context.newPage();
try {
await page.goto('https://example.com/account/reports', {
waitUntil: 'domcontentloaded',
timeout: 45_000,
});
// A semantic readiness check catches login redirects and incomplete pages.
await page.getByRole('heading', { name: 'Reports' }).waitFor({ timeout: 30_000 });
await page.screenshot({ path: 'account-reports.png', fullPage: true });
console.log('Saved account-reports.png');
} finally {
await browser.close();
}
Run the two scripts in order:
node save-auth.mjs
node capture.mjs
Playwright’s storage state can include cookies, local storage, IndexedDB, and passkey-related authentication state. IndexedDB capture is enabled explicitly above. The standard storage-state mechanism does not automatically persist sessionStorage; if the application depends on it, use the workaround below.
4. Handle sessionStorage only when the site needs it
Most applications do not need this extra step. If a site stores authentication data only in sessionStorage, save that data from the authenticated page and restore it before the target page’s scripts run. Keep the resulting file secret just like the regular state file.
After login succeeds in save-auth.mjs, add:
const sessionStorageData = await page.evaluate(() => {
const values = {};
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
values[key] = sessionStorage.getItem(key);
}
return values;
});
import { writeFile } from 'node:fs/promises';
await writeFile(
'playwright/.auth/session.json',
JSON.stringify(sessionStorageData),
{ mode: 0o600 },
);
In the capture script, read the file and install an initialization script before navigating. Limit injection to the expected origin so unrelated sites do not receive the data:
import { readFile } from 'node:fs/promises';
const sessionValues = JSON.parse(
await readFile('playwright/.auth/session.json', 'utf8'),
);
await context.addInitScript(({ expectedOrigin, values }) => {
if (location.origin !== expectedOrigin) return;
for (const [key, value] of Object.entries(values)) {
sessionStorage.setItem(key, value);
}
}, {
expectedOrigin: 'https://example.com',
values: sessionValues,
});
Register the initialization script before page.goto(). This method is specific to an origin and session. If the application changes its auth mechanism, this workaround may stop working; prefer the site’s supported login flow and Playwright’s standard state when possible. See Playwright’s authentication guide for the documented state and sessionStorage patterns.
5. Choose the right capture scope, size, and format
| Need | Playwright setting | Behavior |
|---|---|---|
| Visible viewport only | fullPage: false (default) |
Captures the current viewport dimensions. |
| Entire scrollable page | fullPage: true |
Captures the full page height; very long pages can produce large images. |
| One region | locator(...).screenshot() |
Captures a selected element after it is visible. |
| Higher pixel density | scale: 'css' or 'device' |
CSS scale keeps output at CSS-pixel dimensions; device scale follows the device scale factor. |
| JPEG or WebP | type: 'jpeg' or 'webp' |
PNG is the default; JPEG and WebP support a quality setting. |
For an element capture, replace the page screenshot call with:
const panel = page.locator('[data-testid="report-panel"]');
await panel.waitFor({ state: 'visible' });
await panel.screenshot({ path: 'report-panel.png' });
For JPEG, use await page.screenshot({ path: 'account-reports.jpg', type: 'jpeg', quality: 85, fullPage: true });. For WebP, use type: 'webp' and a suitable quality value. The output file extension should match the selected format. Review Playwright screenshot options for supported options such as animations, caret visibility, masks, and transparent backgrounds.
Set the viewport explicitly to make captures more repeatable. For a device-pixel output, set a deliberate deviceScaleFactor in the context and choose scale: 'device'. High-density output increases pixel dimensions and file size. A full-page capture is not equivalent to several screenshots stitched by the site; fixed-position elements and lazy-loaded content may behave differently, so inspect the resulting image when those matter.
6. Wait for the page state you actually need
Navigation completion is not necessarily application readiness. Many pages load data after the initial document, and some keep network connections open. Wait for a stable, meaningful element or application state before capture:
await page.getByRole('heading', { name: 'Reports' }).waitFor();
await page.locator('[data-testid="report-table"] tr').first().waitFor();
await page.screenshot({ path: 'reports.png', fullPage: true });
Use a fixed delay only when the page has a known timed transition that has no observable readiness signal. Network-idle waits can hang or be misleading on pages with polling, analytics, or long-lived requests. If images load lazily as the page scrolls, the screenshot may not include them; scroll through the page or trigger the application’s supported loading behavior before capturing, then verify readiness.
For consistent visual output, keep the browser version and execution environment stable. Rendering can vary with the host operating system, browser version, settings, hardware, power source, and headless mode. Use the same environment as the visual baseline when comparing screenshots; see Playwright’s visual comparison guidance.
7. Chrome command-line screenshots and authentication limits
Chrome’s headless CLI can capture a viewport screenshot directly. For example:
chrome --headless --screenshot --window-size=412,892 https://example.com/
Chrome also documents --timeout to cap the wait before capture and --virtual-time-budget for pages with time-dependent behavior. These flags control capture timing; they do not create a login session. A CLI invocation against a protected URL without valid browser state will usually capture a login page or redirect. The CLI screenshot documentation does not establish a complete authenticated-session save-and-restore workflow, so use Playwright storage state for a repeatable new-process workflow. Chrome’s current headless mode shares code with headful Chrome; consult the Chrome Headless documentation for version-specific behavior.
8. cURL, Python, and Node.js options
cURL does not run a browser or execute the page’s JavaScript. It can capture only an HTTP response body, which is not a rendered website screenshot. Python can automate the same browser workflow via Playwright’s Python package. Node.js is shown in the complete examples above. For a quick cURL check of a login redirect or response headers, use:
curl -sS -D response-headers.txt -o response.html \
'https://example.com/account/reports'
That response will not include a browser-rendered screenshot. A cookie jar can send cookies to an HTTP endpoint, but it does not automatically reproduce JavaScript-driven login, browser storage, or page rendering.
Python equivalent with Playwright:
from pathlib import Path
from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
storage_state="playwright/.auth/user.json",
viewport={"width": 1440, "height": 900},
device_scale_factor=1,
color_scheme="light",
)
page = await context.new_page()
try:
await page.goto(
"https://example.com/account/reports",
wait_until="domcontentloaded",
timeout=45_000,
)
await page.get_by_role("heading", name="Reports").wait_for(
timeout=30_000
)
await page.screenshot(path="account-reports.png", full_page=True)
finally:
await browser.close()
asyncio.run(main())
Generate the Python state file using Playwright’s Python login flow or use the same state format only when it was produced by a compatible Playwright version and target browser setup. Install with pip install playwright and playwright install chromium. Keep state files private regardless of language.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF; see the ScreenshotNeo API docs. For a public page, the basic cURL call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
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)
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}`);
These examples capture the supplied target URL; they do not log in as a user or accept a private Playwright state file. For a page that requires authentication, use the authorized browser workflow above unless your application exposes a suitable publicly accessible capture URL.
- Cookie banners are accepted and removed before the shot, along with known newsletter popups and chat widgets; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers report the page verdict and billing status.
- An MCP server gives AI agents tools for screenshots, page information, and PDF capture.
- The Free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
Reliability, security, and cost notes
- State expiration: Login sessions can expire or be revoked. Re-run the authorized login flow when the protected page redirects to sign-in or the expected page signal never appears.
- State confidentiality: Treat state and sessionStorage files as credentials. Keep them out of source control, restrict file access, and do not log their contents. Playwright warns that stored state may enable impersonation; see its authentication security guidance.
- Repeatability: Fix viewport, scale, color scheme, browser version, and environment for comparable images. Dynamic data, animation, fonts, and third-party content can still cause visual differences.
- Performance: Reusing an authenticated state avoids repeating interactive login for each capture, but each browser launch and page load still consumes time and resources. Reuse a browser process for batches when appropriate, while creating isolated contexts to keep sessions and page state separate.
- Capture size: Full-page and high-density captures use more memory and produce larger files. Prefer an element or viewport screenshot when the complete page is not needed; use JPEG or WebP when reduced image size is more useful than lossless output.
- Cost: A local Playwright workflow has no per-screenshot API charge, but you operate the browser, storage, compute, and credential handling. ScreenshotNeo’s published plans are Free (1,000/month), Starter ($5 for 3,000), Growth ($15 for 15,000), Pro ($39 for 60,000), Scale ($99 for 250,000), and Business ($249 for 1,000,000); yearly billing gives two months free, and every feature is on every plan. These API plans do not make a private login session available automatically.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The screenshot is the login page | State was saved before login completed, expired, or the target redirects to another auth origin. | Wait for a reliable signed-in signal before saving; inspect the final URL; refresh state through the authorized login flow. |
| Login succeeds but the next context is signed out | The application uses sessionStorage or another state mechanism not restored automatically. | Check the application’s auth design; use the origin-limited sessionStorage workaround if applicable, or use the site’s supported authentication path. |
| Wait for URL or element times out | The assumed URL or selector differs, login failed, MFA remains pending, or the page never reached its ready state. | Use the actual final URL and a stable signed-in element; inspect the page and handle authorized MFA manually or through the approved flow. |
| Screenshot is blank or missing data | Capture ran before client-side data or fonts loaded, or the site blocked automation. | Wait for the specific content element; inspect navigation errors and console output; do not attempt to bypass access controls. |
| Full-page image omits lazy images | Images load only when their region enters the viewport. | Scroll through the page and wait for the images or use the page’s own load behavior before capture. |
| Browser cannot start | The Playwright browser binary is missing or incompatible with the installed package. | Run npx playwright install chromium (or the Python install command) and use the browser version managed for that Playwright installation. |
| State file is missing or rejected | Wrong path, malformed JSON, permissions, or incompatible/stale state. | Check the relative working directory and file permissions; regenerate state through the login script. |
| Images differ between runs | Dynamic page data, rendering environment, device scale, animation, or browser version changed. | Fix the viewport and environment, wait for stable content, and disable or mask volatile content using documented screenshot options. |
FAQ
Does headless Chrome bypass a login?
No. It runs the browser without a visible window. The site still requires valid authentication and may enforce additional checks.
Can I reuse the saved state indefinitely?
No. The site may expire, rotate, or revoke session credentials. Refresh the state when authentication no longer works.
Can I use Chrome’s CLI after signing in manually?
A CLI capture can use a browser profile that already has access, but the screenshot flags themselves do not establish or serialize an authenticated session. For reproducible automation, use a framework with explicit state handling.
Will this capture work for every site?
No. Authentication design, MFA, bot checks, redirects, browser storage, and page readiness differ by site. Adapt the success condition and storage handling to the authorized application.


