Screenshot a page behind a login

Dashboards, invoices and admin pages only render for a logged-in user, so the headless browser needs your session: a cookie or an auth header. Both take one line, and both are easy to get wrong in a way that hands your token to other servers. We measured it with Puppeteer 25.15 and Playwright 1.64, then added cookies and headers to the Shotmint API with those leaks closed.

What goes wrong (measured)

What we didResult
Puppeteer page.setCookie({ name, value }) with no url or domainThrows "At least one of the url and domain needs to be specified" (browser.setCookie: "Invalid cookie fields")
Cookie set after page.gotoThe first load has no cookie (httpbin.org/cookies returned {}), so you capture the login page
Puppeteer page.setExtraHTTPHeaders({ Authorization }) on github.comToken sent with 124 requests to github.githubassets.com, a different host, plus the 2 to github.com
Playwright route.continue({ headers }), only for the target's originThe token followed a redirect to another site (an httpbin.org redirect to postman-echo.com echoed it back)
Puppeteer request interception, only for the target's originSafe: every redirect hop goes through the handler again

Extra headers apply to every request the page makes: scripts, images, fonts, analytics. Use cookies where you can: the browser only sends a cookie to the domain it belongs to. If the page needs a header, add it only to requests on the page's own origin, and check what your library does on redirects.

With the Shotmint API

Send cookies and/or headers in a POST JSON body to /v1/screenshot or /v1/pdf. This Node script logs in to a public demo site (the-internet.herokuapp.com, whose /secure page redirects to /login without a session), then captures the logged-in page:

// node login-screenshot.mjs  (Node 18+, no dependencies)
const SITE = 'https://the-internet.herokuapp.com';

// 1. Log in like a browser would and keep the session cookie.
const login = await fetch(SITE + '/authenticate', {
  method: 'POST',
  body: new URLSearchParams({ username: 'tomsmith', password: 'SuperSecretPassword!' }),
  redirect: 'manual',
});
const cookies = login.headers.getSetCookie().map(c => {
  const [name, ...rest] = c.split(';')[0].split('=');
  return { name, value: rest.join('=') };
});

// 2. Screenshot the logged-in page with that session.
const res = await fetch('https://shotmint.creasework.com/v1/screenshot', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ url: SITE + '/secure', cookies, full_page: true }),
});
if (!res.ok) throw new Error(await res.text());
await (await import('node:fs/promises')).writeFile('secure.png', Buffer.from(await res.arrayBuffer()));
console.log('saved secure.png,', res.headers.get('x-credits-remaining'), 'credits left');

The same in Python, rendered to PDF:

# pip install requests
import os
import requests

SITE = "https://the-internet.herokuapp.com"

# 1. Log in with a normal session; it keeps the cookies the site sets.
s = requests.Session()
s.post(SITE + "/authenticate", data={"username": "tomsmith", "password": "SuperSecretPassword!"})
cookies = [{"name": c.name, "value": c.value} for c in s.cookies]

# 2. Render the logged-in page to PDF with that session.
r = requests.post(
    "https://shotmint.creasework.com/v1/pdf",
    headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
    json={"url": SITE + "/secure", "cookies": cookies},
    timeout=60,
)
r.raise_for_status()
open("secure.pdf", "wb").write(r.content)
print("saved secure.pdf,", r.headers["X-Credits-Remaining"], "credits left")

And a header instead of a cookie, with curl:

# A page that wants a token in a header instead of a cookie (basic auth here)
curl -X POST https://shotmint.creasework.com/v1/screenshot \
  -H "X-API-Key: $SHOTMINT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://httpbin.org/basic-auth/user/pass",
       "headers": {"Authorization": "Basic dXNlcjpwYXNz"}}' \
  -o basic-auth.png

All three were run against the live API: the screenshot and the PDF show "Secure Area", and the basic-auth page returns "authenticated": true. What the API does with them:

ParamBehaviour
cookies[{"name": "sid", "value": "…"}] or "a=1; b=2". Without a domain, a cookie belongs to the URL's host. Set before the first request.
Optional: domain (the host or a parent of it), path, secure, httpOnly, sameSite. Max 50.
headersSent only with requests on the URL's origin, never with third-party assets, and not re-sent after a redirect (pass the final URL).
Max 20; Host, Cookie and other browser-managed headers are refused.
WherePOST JSON body only. In a query string they get a 400, because URLs end up in logs and browser history.
Not with html: there is no site to log in to.

Cookies and headers are used for that render only and are not logged. With cache_ttl, the rendered image is cached (in memory and on disk, for at most 30 days) for your key only.

With Puppeteer

If you run your own browser: set cookies on the browser before goto, with a domain, and add headers only to same-origin requests:

// npm i puppeteer   (Puppeteer 24+)
import puppeteer from 'puppeteer';

const target = new URL('https://the-internet.herokuapp.com/secure');
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });

// Cookies: set them on the browser BEFORE goto, with a domain.
await browser.setCookie({
  name: 'rack.session',
  value: process.env.SESSION_COOKIE,
  domain: target.hostname,
  path: '/',
});

const page = await browser.newPage();

// Headers: add them only to requests on the target's origin.
await page.setRequestInterception(true);
page.on('request', req => {
  if (new URL(req.url()).origin !== target.origin) return req.continue();
  req.continue({ headers: { ...req.headers(), 'x-team': 'reports' } });
});

await page.goto(target.href, { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'secure.png', fullPage: true });
await browser.close();

Run with SESSION_COOKIE=… node puppeteer-login.mjs. On Ubuntu 23.10+ Chrome needs --no-sandbox (AppArmor), as above. With Playwright, context.addCookies([{ name, value, url }]) before goto is the safe path; avoid route.continue({ headers }) for tokens because of the redirect leak above.

Getting a session that lasts

Related: screenshots without cookie banners, full-page screenshots with lazy images, and the API parameters.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: Headless browser how-tos

All guides: guides · integrations · comparisons