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:

  • domain is required and must match the page host, or a parent domain the app uses (example.com for app.example.com).
  • Set secure: true for HTTPS apps that mark their cookie Secure.
  • 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-URL CDN link, which needs no authentication. Do not share it. Set cache.ttl to its minimum, 3,600 seconds, and remove the entry when you have downloaded the file: send the X-Cache-Key value to POST /v1/cache/purge as shown in cache invalidation.
  • Request logs. Your dashboard's request logs store the parameters of each request. Cookie values and network.auth usernames, passwords and tokens are replaced with [FILTERED]. Custom network.headers are stored as sent, so put credentials in network.auth or network.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 in this topic

Was this page helpful?