To screenshot authenticated pages, give the renderer the same credential your browser would send: a session cookie in network.cookies, a token in network.auth or network.headers, or a username and password in network.auth for HTTP basic auth. Then guard the capture with wait.for_selector so a redirect to the login page fails instead of producing a picture of the login form. This guide covers all four methods against a small test app you can run yourself, and ends with the security notes that matter when the page shows private data. It is part of the guides; the option reference is network options.
Tested on October 15, 2026, with Node.js 22.18.0: the test app, the login-and-capture script against a local mock of the RenderScreenshot API, and each credential type in headless Chrome (Puppeteer 24.43.1) to confirm the test app accepts it. SDK examples ran in Node.js, Python, Ruby, PHP and Go against the mock; Java was not run.
Pick a method
| The page is protected by | Send | Example |
|---|---|---|
| A login form and a session cookie | network.cookies |
SaaS dashboards, admin panels |
| HTTP basic auth | network.auth with type: "basic" |
Staging and preview sites |
| A bearer token | network.auth with type: "bearer" |
Internal report pages, token-gated previews |
| A custom header | network.headers |
X-Tenant, preview tokens, feature flags |
Network options are POST-only; the GET query parameters do not include them. Never put credentials in a query string in any case: URLs end up in logs.
A test app to practise on
This app has one page per protection type. Run it locally to check your code, then point the same code at your real app.
// test-app.mjs: a tiny site with three kinds of protection, for testing authenticated captures. // Node 22+, no dependencies. Run: node test-app.mjs (listens on :8080) import http from 'node:http'; import { randomBytes } from 'node:crypto'; const sessions = new Set(); const USER = { email: '[email protected]', password: 'demo-password' }; const BASIC = 'Basic ' + Buffer.from('staging:preview').toString('base64'); const TOKEN = 'Bearer report-token-123'; const page = (title, body) => `<!doctype html><title>${title}</title><h1 id="title">${title}</h1>${body}`; const send = (res, status, html, headers = {}) => { res.writeHead(status, { 'Content-Type': 'text/html', ...headers }); res.end(html); }; const cookies = (req) => Object.fromEntries((req.headers.cookie ?? '').split(';').filter(Boolean).map((c) => c.trim().split('='))); http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); if (url.pathname === '/login' && req.method === 'POST') { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const form = new URLSearchParams(body); if (form.get('email') !== USER.email || form.get('password') !== USER.password) return send(res, 401, page('Sign in', '<p>Wrong password</p>')); const sid = randomBytes(16).toString('hex'); sessions.add(sid); send(res, 303, '', { Location: '/dashboard', 'Set-Cookie': `sid=${sid}; Path=/; HttpOnly; SameSite=Lax` }); }); return; } if (url.pathname === '/dashboard') { if (!sessions.has(cookies(req).sid)) return send(res, 302, '', { Location: '/login' }); return send(res, 200, page('Dashboard', '<p class="welcome">Signed in</p>')); } if (url.pathname === '/login') return send(res, 200, page('Sign in', '<form method="post"><input name="email"><input name="password" type="password"></form>')); if (url.pathname === '/staging') { if (req.headers.authorization !== BASIC) return send(res, 401, page('Unauthorized', ''), { 'WWW-Authenticate': 'Basic realm="staging"' }); return send(res, 200, page('Staging', '<p>Preview build</p>')); } if (url.pathname === '/report') { if (req.headers.authorization !== TOKEN) return send(res, 401, page('Unauthorized', '')); return send(res, 200, page('Report', `<p>Tenant ${req.headers['x-tenant'] ?? 'none'}</p>`)); } send(res, 404, page('Not found', '')); }).listen(Number(process.env.PORT ?? 8080), () => console.log('test app on :8080'));
Without a cookie, /dashboard redirects to /login. With the cookie from a successful login, it shows Signed in. That redirect is the behaviour your capture code must detect.
Session cookies
Sign in over HTTP, keep the redirect response so you can read Set-Cookie, and pass the cookie to the API. Use a dedicated screenshot user with read-only access, never a person's account.
// capture-dashboard.mjs: sign in to your app over HTTP, then capture a logged-in page with the session cookie. // Node 22+, no dependencies. Use a dedicated read-only screenshot user, never a real person's account. import { writeFile } from 'node:fs/promises'; const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const APP = process.env.APP_URL ?? 'https://app.example.com'; // 1. Log in the way a browser would, but keep the redirect so we can read Set-Cookie. const login = await fetch(`${APP}/login`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email: process.env.SCREENSHOT_USER, password: process.env.SCREENSHOT_PASSWORD }), redirect: 'manual', }); const setCookie = login.headers.getSetCookie().find((c) => c.startsWith('sid=')); if (!setCookie) throw new Error(`login failed with HTTP ${login.status}`); const sid = setCookie.split(';')[0].slice('sid='.length); // 2. Capture with the cookie. Fail if the app sends us back to the login page. const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: `${APP}/dashboard`, network: { cookies: [{ name: 'sid', value: sid, domain: new URL(APP).hostname, path: '/', secure: APP.startsWith('https'), http_only: true }] }, wait: { for_selector: '.welcome', timeout: 30 }, capture: { mode: 'full_page' }, cache: { ttl: 3600 }, }), }); if (!res.ok) throw new Error(`capture failed: ${res.status} ${await res.text()}`); await writeFile('dashboard.png', Buffer.from(await res.arrayBuffer())); console.log('saved dashboard.png; cache key', res.headers.get('x-cache-key'));
With the right password it printed saved dashboard.png; with a wrong password it stopped with login failed with HTTP 401 before calling the API, so no credit was used.
Cookie rules that cause most failures:
domainis required and must match the page host, or a parent domain the app uses (example.comforapp.example.com).- Set
secure: truefor HTTPS apps that mark their cookieSecure. - Copy every cookie the app needs. Some apps split the session across two cookies, or add a CSRF cookie the page checks on load.
- Session lifetimes are short by design. Sign in once per job, not once per deploy.
Basic auth
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://staging.example.com/", "network": { "auth": { "type": "basic", "username": "staging", "password": "'"$STAGING_PASSWORD"'" } } }' --output staging.png
The API rejects type: "basic" without both a username and a password with a validation_error, before any render starts.
Bearer tokens and custom headers
{ "url": "https://app.example.com/report", "network": { "auth": { "type": "bearer", "token": "<token>" }, "headers": { "X-Tenant": "acme" } }, "wait": { "for_selector": "#report-table" } }
network.headers go with the page's requests, so an API the page calls from the browser can read the same header. Prefer network.auth for the credential itself; see the security notes below for why.
Guard against the login page
Every method above fails the same way: the credential is wrong or expired, the app redirects to its login page, and the API returns a crisp image of a login form. Make that case fail:
{ "url": "https://app.example.com/dashboard", "network": { "cookies": [{ "name": "sid", "value": "<session>", "domain": "app.example.com" }] }, "wait": { "for_selector": ".welcome", "timeout": 20 } }
Pick a selector that exists only for signed-in users: the account menu, a user name, the main data table. If the login page loads instead, the wait times out and you get a target_error. Failed renders cost no credits, so the guard is free.
SDK examples
Each SDK has builder methods for cookies, basic auth, bearer auth and headers. The Java SDK reference documents no cookie method, so its example uses basic auth; send network.cookies in a raw POST body from Java.
Node.js
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://app.example.com/dashboard') .cookies([{ name: 'sid', value: session, domain: 'app.example.com' }]) .waitForSelector('.welcome') );
Python
image = client.take( TakeOptions.url('https://staging.example.com/') .auth_basic('staging', os.environ['STAGING_PASSWORD']) )
Ruby
image = client.take( RenderScreenshot::TakeOptions.url('https://app.example.com/report') .auth_bearer(ENV.fetch('REPORT_TOKEN')).headers({ 'X-Tenant' => 'acme' }) )
PHP
$image = $client->take( TakeOptions::url('https://app.example.com/dashboard') ->cookies([['name' => 'sid', 'value' => $session, 'domain' => 'app.example.com']]) ->waitForSelector('.welcome') );
Java
byte[] image = client.take( TakeOptions.url("https://staging.example.com/") .authBasic("staging", System.getenv("STAGING_PASSWORD")) );
Go
image, err := client.Take(ctx, rs.URL("https://app.example.com/report"). AuthBearer(os.Getenv("REPORT_TOKEN")). Headers(map[string]string{"X-Tenant": "acme"}))
Security notes
- The image is a public link until it expires. Every render is stored and served from the
X-Cache-URLCDN link, which needs no authentication. Do not share it. Setcache.ttlto its minimum, 3,600 seconds, and remove the entry when you have downloaded the file: send theX-Cache-Keyvalue toPOST /v1/cache/purgeas shown in cache invalidation. - Request logs. Your dashboard's request logs store the parameters of each request. Cookie values and
network.authusernames, passwords and tokens are replaced with[FILTERED]. Customnetwork.headersare stored as sent, so put credentials innetwork.authornetwork.cookies, not in a custom header. - Least privilege. Use a screenshot user that can only read, in a role that cannot see billing or personal data you do not want in an image.
- Short-lived credentials. Mint a session or token per job and revoke it after. A leaked one-hour token is a small problem; a leaked admin password is not.
- Our key stays on the server. These requests carry your RenderScreenshot API key and the app credential. Run them from your backend or a job runner, never from a browser.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Image shows the login page | Missing or expired cookie; redirect followed | Re-login per job; add a wait.for_selector guard |
timeout error on a page that works in your browser |
Guard selector never appears, so the credential was rejected | Check the cookie name, value and domain |
| Cookie ignored | domain does not match the host, or secure missing on HTTPS |
Use the exact host or its parent domain; set secure: true |
| Works locally, fails in the API | Session bound to your IP address or device | Use a token or basic auth for the screenshot user, or relax IP binding for it |
| 401 from the target with basic auth | Wrong realm credentials, or the app uses a form login | Test with curl -u user:pass first |
validation_error on network.auth |
type is not basic or bearer, or a field is missing |
Basic needs username and password; bearer needs token |
| Data loads, then the page signs out | The page refreshes its session with a cookie you did not send | Copy all cookies from the login response |
| Pages behind SSO or MFA | The login needs a browser and a second factor | Create a service user with a long-lived token for screenshots |
Related parameters
- Network options:
cookies,headers,auth,bypass_csp. - Wait options:
for_selectorandtimeoutfor the login guard. - Customize the page before capture: hide account names or emails with
page.hide. - Coming from a Puppeteer login script? See migrate from Puppeteer.
- Create a free API key and run the test app against it.