BlogEngineering Practices

How to Capture Documentation Screenshots Behind Login

Authentication is the #1 blocker for automated documentation screenshots. Four approaches solve it — localhost capture, session persistence, cookie injection, and short-lived token URLs. Here is when to use each.

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.

YAML
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:3000

The 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

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.

JavaScript
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 addCookies API instead of document.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:

JavaScript
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:

JavaScript
// 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.

Start with one screenshot workflow.

Run one real docs capture on the free tier and see whether the maintenance disappears.

Start for free