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 we did | Result |
|---|---|
Puppeteer page.setCookie({ name, value }) with no url or domain | Throws "At least one of the url and domain needs to be specified" (browser.setCookie: "Invalid cookie fields") |
Cookie set after page.goto | The first load has no cookie (httpbin.org/cookies returned {}), so you capture the login page |
Puppeteer page.setExtraHTTPHeaders({ Authorization }) on github.com | Token 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 origin | The 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 origin | Safe: 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.
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:
| Param | Behaviour |
|---|---|
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. |
headers | Sent 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. |
| Where | POST 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.
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.
selector miss returns a 400).Related: screenshots without cookie banners, full-page screenshots with lazy images, and the API parameters.
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).
All guides: guides · integrations · comparisons