Authentication is the #1 technical blocker for documentation screenshot automation. Your product requires login to access any meaningful UI. A headless browser hitting your production URL sees the login page, not the dashboard. Four approaches solve this problem — each with different tradeoffs.
Why automating login is the wrong approach
The intuitive solution is to automate the login flow: navigate to the login page, fill in the username and password fields, click submit, wait for redirect, then capture. This is also the most fragile approach.
Do not automate login form submission. It breaks on CAPTCHA challenges, MFA prompts, rate limiting, OAuth redirects, SSO flows, selector changes to the login form, and session cookie timing. Every one of these will cause your CI pipeline to fail silently or produce a screenshot of an error page instead of the dashboard. The failure mode is a screenshot of the wrong thing — worse than no screenshot at all.
Every tool and platform that has tried to solve auth by automating the login form has documented its limitations. The lesson is clear: bypass the login, do not automate it.
Approach 1: localhost capture (bypass auth entirely)
The simplest approach. Run your application locally in CI with authentication disabled or a seeded test user already logged in. Capture against http://localhost:3000. The browser never sees a login page because the local instance is pre-authenticated.
jobs:
screenshots:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Seed database with test data
run: npm run db:seed
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/testdb
- name: Start app with auth disabled
run: npm start &
env:
AUTH_DISABLED: true
DATABASE_URL: postgres://postgres:test@localhost:5432/testdb
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Capture screenshots
run: npx reshot run --headless
env:
TARGET_URL: http://localhost:3000The key is AUTH_DISABLED=true. Your application should have an environment variable that skips authentication middleware in non-production environments. This is not a security risk — the app runs inside the CI runner with no external access, against a disposable test database.
When localhost works:
- Your application can run in CI (Node.js, Python, Ruby, Go — most web apps can)
- You have a mechanism to disable or bypass auth in test environments
- You can seed the database with realistic test data for the screenshots
When localhost does not work:
- Your application depends on external services that cannot be mocked in CI
- You cannot run your full application stack in a CI runner
- The screenshots need to show production data (real dashboards with real metrics)
This is Reshot's recommended approach. The reshot run --headless step in your CI pipeline captures against localhost — no production credentials, no session management, no auth complexity. The application starts with test data; the capture runs; the images upload to CDN.
Approach 2: encrypted session persistence
Log in once in a real browser. Save the session. Reuse it in headless captures.
Some tools implement this natively: a setup command opens a browser window, you navigate to your application and log in manually — SSO, MFA, CAPTCHA, whatever your auth flow requires — and the tool encrypts and saves the session cookies. Future captures, including headless CI runs, reuse the saved session automatically.
When session persistence works:
- Your auth flow is complex (SSO, MFA, OAuth) and cannot be bypassed in CI
- You need to capture against a staging or production environment, not localhost
- Session cookies have a long expiry (days or weeks)
When session persistence does not work:
- Session cookies expire frequently (hours) — you would need to re-authenticate before each CI run
- Your auth system rotates session tokens on every request
- Security policies prohibit storing session credentials in CI secrets
Approach 3: cookie injection in Playwright
If you use Playwright scripts or shot-scraper for capture, you can inject cookies directly into the browser context before navigating to the target page.
const { chromium } = require('playwright');
async function captureAuthenticated(url, cookies, outputPath) {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }
});
// Inject session cookies before navigating
await context.addCookies(cookies);
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({ path: outputPath });
await browser.close();
}
// Usage: export cookies from your browser's DevTools
captureAuthenticated(
'https://app.example.com/dashboard',
[
{
name: 'session_id',
value: process.env.SESSION_COOKIE,
domain: 'app.example.com',
path: '/',
httpOnly: true,
secure: true
}
],
'screenshots/dashboard.png'
);When cookie injection works:
- You have programmatic access to valid session cookies or tokens
- Your application accepts cookie-based authentication (most web apps do)
- You can generate or extract cookies in CI
When cookie injection does not work:
- Your auth system uses HTTP-only cookies that cannot be set via JavaScript (use Playwright's
addCookiesAPI instead ofdocument.cookie) - The application validates cookies against IP address or user-agent
- CSRF tokens are required alongside session cookies (inject both)
Approach 4: short-lived token URLs
For internal tools where you control the auth layer, generate a short-lived access token server-side, embed it in the URL, capture the screenshot, and expire the token immediately:
const jwt = require('jsonwebtoken');
// Generate a token that expires in 60 seconds
app.get('/api/screenshot-token', requireServiceAuth, (req, res) => {
const token = jwt.sign(
{ purpose: 'screenshot', user: 'docs-bot' },
process.env.JWT_SECRET,
{ expiresIn: '60s' }
);
res.json({ url: `https://app.example.com/dashboard?token=${token}` });
});
// Middleware: accept token from URL query param
app.use((req, res, next) => {
const token = req.query.token;
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.purpose === 'screenshot') {
req.user = { id: 'docs-bot', role: 'viewer' };
return next();
}
} catch (e) { /* token expired or invalid — fall through to normal auth */ }
}
next();
});The capture script calls the token endpoint, receives a signed URL, captures against it, and the token expires. No persistent cookies, no stored credentials, no session management.
When token URLs work:
- You control the application's auth middleware
- You can add a screenshot-specific auth endpoint
- Security policy requires zero-persistence credentials
When token URLs do not work:
- You do not control the application's auth layer (third-party SaaS, vendor product)
- The application has no API for generating tokens programmatically
The pragmatic recommendation
Start with localhost. If your application runs in CI (and most web apps can, with Docker or a lightweight setup), disabling auth for the screenshot job is the simplest, most secure, and most reliable approach. You never store production credentials, you never manage session expiry, and your screenshots show a controlled test environment with predictable data. Move to cookie injection or session persistence only if localhost is not viable.
Seeding realistic test data
Localhost capture solves authentication but introduces a new question: what data does the screenshot show? An empty dashboard with "No data available" is technically accurate but useless for documentation.
The solution is a database seed script that populates realistic but fake data before capture:
// scripts/seed-docs-data.mjs
await db.users.create({ name: 'Acme Corp', plan: 'pro' });
await db.metrics.createMany([
{ name: 'MRR', value: 12400, date: '2026-03-01' },
{ name: 'MRR', value: 13100, date: '2026-04-01' },
{ name: 'Active users', value: 847, date: '2026-04-01' },
]);
await db.webhooks.create({
url: 'https://hooks.example.com/events',
events: ['order.created', 'payment.completed'],
status: 'active'
});Commit the seed script alongside your screenshot config. Both are versioned, reviewable, and deterministic. The screenshots always show the same data — no production leaks, no empty states, no "test123" usernames visible in published documentation.
For the full CI pipeline around this, see how to keep screenshots up to date.
Frequently asked questions
Why is authentication the hardest part of screenshot automation? Most SaaS products require login for any meaningful UI. A headless browser hitting your URL sees the login page. Automating the login flow is fragile — CAPTCHAs, MFA, OAuth redirects, and selector changes all break it. The solution is to bypass login, not automate it.
What is the localhost approach?
Run your application in CI with auth disabled and a seeded test database. Capture against http://localhost:3000. The browser never sees a login page. This is Reshot's recommended approach.
Can I inject cookies into Playwright for authenticated screenshots?
Yes. Playwright's browserContext.addCookies() accepts an array of cookie objects. Export cookies from your browser's DevTools, store the session value as a CI secret, and inject before navigating. This also works with shot-scraper's JavaScript injection field.
Which approach should I use? Start with localhost capture — it is the simplest, most secure, and most reliable. Use session persistence if you cannot run your app in CI. Use cookie injection for Playwright/shot-scraper setups where you have programmatic access to tokens. Use token URLs for internal tools where you control the auth layer. Avoid automating the login form.

