Back to guides

Screenshot authenticated pages

Last updated september 29, 2026

ApiFlash captures each screenshot with a fresh browser that isn't logged in. Here is how to give it access to pages behind a login. Options 1 and 2 require changing the code of the website, so they only work for websites you own.

Option Works with Best for
1. Send a secret in a header Your website only Quick setup
2. Send a signed token in a header Your website only Extra security, since leaked tokens expire
3. Pass session cookies Any website Websites that log users in with a cookie
4. Pass HTTP headers Any website Basic authentication or bearer tokens
5. Log in with JavaScript Any website When you can't get a session cookie ahead of time

Parameter values are shown unencoded below for readability, but must be URL encoded in your API calls.

Option 1 • Send a secret in a header

Send a secret string with the headers parameter:

https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url=https://app.example.com/reports/42&headers=X-Screenshot-Secret=YOUR_SECRET

And make your website verify the secret before rendering the page:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const crypto = require('crypto');

function isScreenshotRequest(req) {
    const received = Buffer.from(req.get('X-Screenshot-Secret') || '');
    const expected = Buffer.from(process.env.SCREENSHOT_SECRET);
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

app.get('/reports/:id', (req, res) => {
    if (!req.user && !isScreenshotRequest(req)) {
        return res.redirect('/login');
    }
    res.render('report', {report: getReport(req.params.id)});
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import hmac
import os

from django.contrib.auth.views import redirect_to_login
from django.shortcuts import get_object_or_404, render

from .models import Report


def is_screenshot_request(request):
    received = request.headers.get('X-Screenshot-Secret', '').encode()
    return hmac.compare_digest(received, os.environ['SCREENSHOT_SECRET'].encode())


def report(request, report_id):
    if not request.user.is_authenticated and not is_screenshot_request(request):
        return redirect_to_login(request.get_full_path())
    return render(request, 'report.html', {'report': get_object_or_404(Report, pk=report_id)})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

namespace App\Http\Controllers;

use App\Models\Report;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

// In routes/web.php: Route::get('/reports/{report}', [ReportController::class, 'show']);
// In config/services.php: 'screenshot' => ['secret' => env('SCREENSHOT_SECRET')],
class ReportController extends Controller
{
    public function show(Request $request, Report $report)
    {
        if (!Auth::check() && !$this->isScreenshotRequest($request)) {
            return redirect()->route('login');
        }
        return view('report', ['report' => $report]);
    }

    private function isScreenshotRequest(Request $request): bool
    {
        $secret = config('services.screenshot.secret') ?: throw new \RuntimeException('SCREENSHOT_SECRET is not set.');
        return hash_equals($secret, $request->header('X-Screenshot-Secret', ''));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class ReportsController < ApplicationController
  skip_before_action :authenticate_user!, if: :screenshot_request?

  def show
    @report = Report.find(params[:id])
  end

  private

  def screenshot_request?
    ActiveSupport::SecurityUtils.secure_compare(
      request.headers['X-Screenshot-Secret'].to_s, ENV.fetch('SCREENSHOT_SECRET')
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Controller
public class ReportController {

    private static final byte[] SCREENSHOT_SECRET =
            System.getenv("SCREENSHOT_SECRET").getBytes(StandardCharsets.UTF_8);

    private final ReportRepository reportRepository;

    public ReportController(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    static boolean isScreenshotRequest(String secret) {
        return secret != null
                && MessageDigest.isEqual(secret.getBytes(StandardCharsets.UTF_8), SCREENSHOT_SECRET);
    }

    // Also permit "/reports/**" in your Spring Security configuration.
    @GetMapping("/reports/{id}")
    public String report(@PathVariable long id,
                         @RequestHeader(name = "X-Screenshot-Secret", required = false) String secret,
                         Principal principal, Model model) {
        if (principal == null && !isScreenshotRequest(secret)) {
            return "redirect:/login";
        }
        model.addAttribute("report", reportRepository.findById(id).orElseThrow());
        return "report";
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var screenshotSecret = []byte(os.Getenv("SCREENSHOT_SECRET"))

func isScreenshotRequest(c *gin.Context) bool {
	received := []byte(c.GetHeader("X-Screenshot-Secret"))
	return len(screenshotSecret) > 0 && subtle.ConstantTimeCompare(received, screenshotSecret) == 1
}

// router.GET("/reports/:id", showReport)
func showReport(c *gin.Context) {
	if currentUser(c) == nil && !isScreenshotRequest(c) {
		c.Redirect(http.StatusFound, "/login")
		return
	}
	c.HTML(http.StatusOK, "report.html", gin.H{"report": getReport(c.Param("id"))})
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
using System.Security.Cryptography;
using System.Text;

static bool IsScreenshotRequest(HttpRequest request, IConfiguration config)
{
    var received = Encoding.UTF8.GetBytes(request.Headers["X-Screenshot-Secret"].ToString());
    var expected = Encoding.UTF8.GetBytes(config["ScreenshotSecret"]!);
    return CryptographicOperations.FixedTimeEquals(received, expected);
}

app.MapGet("/reports/{id}", (int id, HttpContext context, IConfiguration config) =>
{
    if (context.User.Identity?.IsAuthenticated != true && !IsScreenshotRequest(context.Request, config))
    {
        return Results.Redirect("/login");
    }
    return Results.Content(RenderReport(id), "text/html");
});

To generate a random secret, you can run openssl rand -hex 32 for example.

Headers are also sent to the third party resources loaded by the page, like CDNs or analytics. If a leaked secret is a concern, use option 2.

Option 2 • Send a signed token in a header

Instead of sending the secret itself, send a token signed with it that expires after 5 minutes:

https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url=https://app.example.com/reports/42&headers=X-Screenshot-Token=TOKEN

Generate a token on your server for each API call and check that token instead of the secret from option 1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const crypto = require('crypto');

function sign(value) {
    return crypto.createHmac('sha256', process.env.SCREENSHOT_SECRET).update(value).digest('hex');
}

function screenshotToken() {
    const expires = String(Math.floor(Date.now() / 1000) + 300);
    return `${expires}.${sign(expires)}`;
}

function isScreenshotRequest(req) {
    const [expires, signature] = (req.get('X-Screenshot-Token') || '').split('.');
    if (!signature || !(Number(expires) > Date.now() / 1000)) {
        return false;
    }
    const received = Buffer.from(signature);
    const expected = Buffer.from(sign(expires));
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import hashlib
import hmac
import os
import time


def sign(value):
    return hmac.new(os.environ['SCREENSHOT_SECRET'].encode(), value.encode(), hashlib.sha256).hexdigest()


def screenshot_token():
    expires = str(int(time.time()) + 300)
    return f'{expires}.{sign(expires)}'


def is_screenshot_request(request):
    expires, _, signature = request.headers.get('X-Screenshot-Token', '').partition('.')
    if not (expires.isascii() and expires.isdigit()) or int(expires) < time.time():
        return False
    return hmac.compare_digest(signature.encode(), sign(expires).encode())
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

namespace App\Support;

// In config/services.php: 'screenshot' => ['secret' => env('SCREENSHOT_SECRET')],
class ScreenshotToken
{
    public static function generate(): string
    {
        $expires = (string) (time() + 300);
        return $expires . '.' . self::sign($expires);
    }

    public static function isValid(?string $token): bool
    {
        $parts = explode('.', $token ?? '', 2);
        if (count($parts) !== 2 || !ctype_digit($parts[0]) || (int) $parts[0] < time()) {
            return false;
        }
        return hash_equals(self::sign($parts[0]), $parts[1]);
    }

    private static function sign(string $value): string
    {
        $secret = config('services.screenshot.secret') ?: throw new \RuntimeException('SCREENSHOT_SECRET is not set.');
        return hash_hmac('sha256', $value, $secret);
    }
}

// In the controller, replace $this->isScreenshotRequest($request) with
// ScreenshotToken::isValid($request->header('X-Screenshot-Token')).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module ScreenshotToken
  def self.generate
    expires = (Time.now.to_i + 300).to_s
    "#{expires}.#{sign(expires)}"
  end

  def self.valid?(token)
    expires, signature = token.to_s.split('.', 2)
    return false unless expires&.match?(/\A\d+\z/) && expires.to_i > Time.now.to_i

    ActiveSupport::SecurityUtils.secure_compare(signature.to_s, sign(expires))
  end

  def self.sign(value)
    OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('SCREENSHOT_SECRET'), value)
  end
end

# In the controller:
# skip_before_action :authenticate_user!, if: -> { ScreenshotToken.valid?(request.headers['X-Screenshot-Token']) }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public final class ScreenshotToken {

    private static final byte[] SECRET = System.getenv("SCREENSHOT_SECRET").getBytes(StandardCharsets.UTF_8);

    public static String generate() {
        String expires = String.valueOf(Instant.now().getEpochSecond() + 300);
        return expires + "." + sign(expires);
    }

    public static boolean isValid(String token) {
        String[] parts = token == null ? new String[0] : token.split("\\.", 2);
        if (parts.length != 2 || !parts[0].matches("\\d{1,18}")
                || Long.parseLong(parts[0]) < Instant.now().getEpochSecond()) {
            return false;
        }
        return MessageDigest.isEqual(parts[1].getBytes(StandardCharsets.UTF_8),
                sign(parts[0]).getBytes(StandardCharsets.UTF_8));
    }

    private static String sign(String value) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }
}

// In the controller, replace isScreenshotRequest(secret) with ScreenshotToken.isValid(token).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
var screenshotSecret = []byte(os.Getenv("SCREENSHOT_SECRET"))

func sign(value string) string {
	mac := hmac.New(sha256.New, screenshotSecret)
	mac.Write([]byte(value))
	return hex.EncodeToString(mac.Sum(nil))
}

func screenshotToken() string {
	expires := strconv.FormatInt(time.Now().Unix()+300, 10)
	return expires + "." + sign(expires)
}

func isScreenshotRequest(c *gin.Context) bool {
	expires, signature, _ := strings.Cut(c.GetHeader("X-Screenshot-Token"), ".")
	timestamp, err := strconv.ParseInt(expires, 10, 64)
	if len(screenshotSecret) == 0 || err != nil || timestamp < time.Now().Unix() {
		return false
	}
	return hmac.Equal([]byte(signature), []byte(sign(expires)))
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Security.Cryptography;
using System.Text;

static string Sign(string value, IConfiguration config)
{
    var key = Encoding.UTF8.GetBytes(config["ScreenshotSecret"]!);
    return Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(value)));
}

static string ScreenshotToken(IConfiguration config)
{
    var expires = (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300).ToString();
    return $"{expires}.{Sign(expires, config)}";
}

static bool IsScreenshotRequest(HttpRequest request, IConfiguration config)
{
    var parts = request.Headers["X-Screenshot-Token"].ToString().Split('.', 2);
    if (parts.Length != 2 || !long.TryParse(parts[0], out var expires)
        || expires < DateTimeOffset.UtcNow.ToUnixTimeSeconds())
    {
        return false;
    }
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(parts[1]), Encoding.UTF8.GetBytes(Sign(parts[0], config)));
}

As the token changes with every API call, screenshots are never served from the cache.

The secret never leaves your server, and a leaked token is useless after 5 minutes.

Option 3 • Pass session cookies

Most websites keep users logged in with a session cookie. Pass that cookie with the cookies parameter, and ApiFlash loads the page as the logged in user:

https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url=https://app.example.com/dashboard&cookies=session_id=YOUR_SESSION_ID;csrf_token=YOUR_CSRF_TOKEN

To get the session cookie, log in with your browser and copy it from the developer tools (Application › Cookies). Cookies are only sent to the domain of the url parameter.

Some session cookies expire. To get fresh ones automatically, log in with a script and build the cookies parameter from the cookies it receives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto('https://app.example.com/login');
    await page.type('input[name="email"]', 'screenshots@example.com');
    await page.type('input[name="password"]', process.env.PASSWORD);
    await Promise.all([page.waitForNavigation(), page.click('button[type="submit"]')]);

    const cookies = (await browser.cookies()).filter(cookie => cookie.domain.endsWith('example.com'));
    await browser.close();
    console.log(cookies.map(cookie => `${cookie.name}=${cookie.value}`).join(';'));
})();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import os

from playwright.sync_api import sync_playwright

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    page = browser.new_page()
    page.goto('https://app.example.com/login')
    page.locator('input[name="email"]').fill('screenshots@example.com')
    page.locator('input[name="password"]').fill(os.environ['PASSWORD'])
    page.locator('button[type="submit"]').click()
    page.wait_for_url('**/dashboard')

    cookies = page.context.cookies('https://app.example.com')
    browser.close()
    print(';'.join(f'{cookie["name"]}={cookie["value"]}' for cookie in cookies))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import os

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait

driver = webdriver.Chrome()
driver.get('https://app.example.com/login')
driver.find_element(By.NAME, 'email').send_keys('screenshots@example.com')
driver.find_element(By.NAME, 'password').send_keys(os.environ['PASSWORD'])
driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
WebDriverWait(driver, 10).until(expected_conditions.url_contains('/dashboard'))

cookies = driver.get_cookies()
driver.quit()
print(';'.join(f'{cookie["name"]}={cookie["value"]}' for cookie in cookies))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import os

import requests

# Without a browser, this only works with simple login forms that don't need JavaScript or a CSRF token.
session = requests.Session()
session.post('https://app.example.com/login', data={
    'email': 'screenshots@example.com',
    'password': os.environ['PASSWORD'],
})
print(';'.join(f'{name}={value}' for name, value in session.cookies.items()))

Option 4 • Pass HTTP headers

Some websites and APIs authenticate each request with a header, usually Authorization. Pass that header with the headers parameter, and ApiFlash sends it when loading the page:

https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url=https://app.example.com/reports/42&headers=Authorization=Bearer YOUR_TOKEN

For Basic authentication, use Authorization=Basic CREDENTIALS where CREDENTIALS is username:password encoded in base64.

Like in option 1, headers are also sent to the third party resources loaded by the page. Prefer cookies if the website supports them, as they are only sent to the domain of the url parameter.

Option 5 • Log in with JavaScript

ApiFlash can also log in by itself, by filling in and submitting the login form of the website before taking the screenshot. Set url to the login page, submit the login form with the js parameter, and set wait_for to a CSS selector that only exists once logged in:

https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url=https://app.example.com/login?next=/reports/42&js=LOGIN_SCRIPT&wait_for=.report

Where LOGIN_SCRIPT fills in and submits the login form:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function fill(selector, value) {
    const input = document.querySelector(selector);
    // Use the native setter so that frameworks like React notice the change.
    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value);
    input.dispatchEvent(new Event('input', {bubbles: true}));
}

fill('input[name="email"]', 'screenshots@example.com');
fill('input[name="password"]', 'YOUR_PASSWORD');
document.querySelector('input[name="password"]').form.requestSubmit();

The screenshot shows the page you land on after the login, which you can often choose with a redirect parameter like /login?next=/reports/42. This doesn't work with CAPTCHAs or two-factor authentication.