Screenshot a site behind Cloudflare

If your site uses Cloudflare bot protection, a screenshot of it can come back as a "Just a moment..." challenge page or a "Sorry, you have been blocked" page instead of your content. This guide is for the owner of the site. It shows how to let only your own screenshot requests through, which Cloudflare features can and cannot be skipped on each plan, and how to make a blocked capture fail cleanly instead of returning the challenge page. It is part of the guides hub. If you do not control the target site, read bot protection and CAPTCHAs instead; we do not bypass other people's protection.

Why Cloudflare stops the renderer

RenderScreenshot renders pages in a headless Chrome on Cloudflare's Browser Rendering platform (Cloudflare now calls it Browser Run). Cloudflare's documentation says that requests from it are always identified as bot traffic, and that every request carries headers which cannot be removed, such as cf-brapi-devtools and a Web Bot Auth Signature-agent. Your zone's bot settings then decide what happens to those requests.

Two things follow from that:

  • A bot rule on your zone treats our renderer like any other automated client. That is the correct default.
  • Allowing "all Cloudflare Browser Rendering traffic" admits every developer who uses that platform, not only your own screenshots. You need something only your requests carry.

IP allowlists are also a poor fit. The requests come from Cloudflare's shared rendering infrastructure, not from addresses dedicated to RenderScreenshot.

Sources: Cloudflare docs on Browser Run automatic request headers, Browser Run FAQ, Bot Fight Mode and skip options, checked on 2026-10-15.

What a blocked capture returns

Our renderer does not treat the target's HTTP status as an error. It captures whatever the browser shows. So a blocked capture ends in one of two ways:

What happened API response Credit used
Cloudflare served a challenge or block page, and it finished loading 200 with an image of the challenge page Yes
The challenge kept the page busy until the wait timeout 422 target_error, code screenshot_failed No

The first case is the expensive one: it looks like success, and your code stores a picture of a challenge. The guard below turns it into the second case.

What each Cloudflare plan lets you exempt

Your zone uses Can a skip rule exempt the renderer? What to do
Bot Fight Mode (Free plan) No. It runs outside the Ruleset Engine Turn Bot Fight Mode off, or upgrade to Super Bot Fight Mode
Super Bot Fight Mode (Pro, Business) Yes Skip rule on a secret user-agent token
WAF managed rules, rate limiting rules, custom challenge rules Yes Same skip rule
Browser Integrity Check, Security Level ("I'm Under Attack") Yes, as skipped products Same skip rule, with those products selected
Bot Management (Enterprise) Yes Skip rule on the token; the Browser Run bot detection ID 119853733 also works, but it matches every Browser Run user
Turnstile widget inside your page No, it is part of the page Do not show it to the capture, or render from HTML instead

Step 1: Make a secret token

Generate a random value and store it with your other secrets:

openssl rand -hex 16
# 3f9c1a7be2d64c0e8a51f7b2c9d0e4a6

You will append it to the user agent of your screenshot requests. Keep a normal Chrome user-agent string in front of it, so your site still serves its usual desktop HTML.

Step 2: Send it with the capture, plus a content guard

The request sets browser.user_agent and adds one script in page.scripts. The script runs after the page loads and throws if an element that only your real page has is missing. A thrown error makes the request fail with 422 and no credit, so a challenge page never reaches your storage. Replace #app with a selector from your own layout.

First, the request without the token. Use it to confirm the problem:

curl -sS "https://api.renderscreenshot.com/v1/screenshot?url=https://www.example.com/pricing&preset=og_card" \
  -H "Authorization: Bearer $RS_API_KEY" -o before.png

Then the request that carries the token and the guard. The user agent is only available in the POST body (it is not a GET parameter), so the token never appears in a URL:

curl -sS https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.example.com/pricing",
    "preset": "og_card",
    "browser": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 SiteCapture/'"$CAPTURE_TOKEN"'"
    },
    "page": {
      "scripts": ["if (!document.querySelector(\"#app\")) throw new Error(\"rs-guard: page content missing\")"]
    }
  }' \
  -o after.png -w "%{http_code}\n"

The listings below send the same body and report a blocked capture separately from other errors. They post JSON directly so that browser and page arrive nested exactly as shown; the full request format is in the raw HTTP guide. The Node, Python, Ruby, PHP and Go listings were run on 2026-10-15 against a local stub of the API that returns both outcomes; the Java listing was not run.

Node.js

// capture.mjs - Node 20+, no dependencies
import { writeFile } from 'node:fs/promises';

const UA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 SiteCapture/${process.env.CAPTURE_TOKEN}`;

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url: 'https://www.example.com/pricing',
    preset: 'og_card',
    browser: { user_agent: UA },
    page: { scripts: ["if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')"] },
  }),
});

if (res.ok) {
  await writeFile('pricing.png', Buffer.from(await res.arrayBuffer()));
} else {
  const { error } = await res.json();
  const blocked = res.status === 422 && error.message.includes('rs-guard');
  console.error(blocked ? 'Blocked or challenged: check the Cloudflare skip rule' : `${res.status} ${error.message}`);
  process.exit(1);
}

Python

# capture.py - Python 3.9+, standard library only
import json, os, sys, urllib.error, urllib.request

UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
      f"Chrome/140.0.0.0 Safari/537.36 SiteCapture/{os.environ['CAPTURE_TOKEN']}")
body = {
    "url": "https://www.example.com/pricing",
    "preset": "og_card",
    "browser": {"user_agent": UA},
    "page": {"scripts": ["if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')"]},
}
req = urllib.request.Request(
    "https://api.renderscreenshot.com/v1/screenshot", data=json.dumps(body).encode(), method="POST",
    headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}", "Content-Type": "application/json"},
)
try:
    with urllib.request.urlopen(req, timeout=90) as res, open("pricing.png", "wb") as f:
        f.write(res.read())
except urllib.error.HTTPError as e:
    err = json.load(e)["error"]
    blocked = e.code == 422 and "rs-guard" in err["message"]
    sys.exit("Blocked or challenged: check the Cloudflare skip rule" if blocked else f"{e.code} {err['message']}")

Ruby

# capture.rb - Ruby 3.0+, standard library only
require 'json'
require 'net/http'

ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' \
     "Chrome/140.0.0.0 Safari/537.36 SiteCapture/#{ENV.fetch('CAPTURE_TOKEN')}"
body = {
  url: 'https://www.example.com/pricing',
  preset: 'og_card',
  browser: { user_agent: ua },
  page: { scripts: ["if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')"] }
}
uri = URI('https://api.renderscreenshot.com/v1/screenshot')
res = Net::HTTP.post(uri, body.to_json,
                     'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json')

if res.is_a?(Net::HTTPSuccess)
  File.binwrite('pricing.png', res.body)
else
  error = JSON.parse(res.body)['error']
  blocked = res.code == '422' && error['message'].include?('rs-guard')
  abort(blocked ? 'Blocked or challenged: check the Cloudflare skip rule' : "#{res.code} #{error['message']}")
end

PHP

<?php
// capture.php - PHP 8.0+ with the curl extension
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) '
    . 'Chrome/140.0.0.0 Safari/537.36 SiteCapture/' . getenv('CAPTURE_TOKEN');
$body = [
    'url' => 'https://www.example.com/pricing',
    'preset' => 'og_card',
    'browser' => ['user_agent' => $ua],
    'page' => ['scripts' => ["if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')"]],
];
$ch = curl_init('https://api.renderscreenshot.com/v1/screenshot');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 90,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode($body),
]);
$out = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($out !== false && $status === 200) {
    file_put_contents('pricing.png', $out);
    exit(0);
}
$message = $out === false ? curl_error($ch) : (json_decode($out, true)['error']['message'] ?? '');
$blocked = $status === 422 && str_contains($message, 'rs-guard');
fwrite(STDERR, ($blocked ? 'Blocked or challenged: check the Cloudflare skip rule' : "{$status} {$message}") . PHP_EOL);
exit(1);

Go

// main.go - Go 1.21+, standard library only
package main

import (
    "bytes"
    "encoding/json"
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"
)

func main() {
    ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " +
        "Chrome/140.0.0.0 Safari/537.36 SiteCapture/" + os.Getenv("CAPTURE_TOKEN")
    body, _ := json.Marshal(map[string]any{
        "url":     "https://www.example.com/pricing",
        "preset":  "og_card",
        "browser": map[string]any{"user_agent": ua},
        "page": map[string]any{"scripts": []string{
            "if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')",
        }},
    })
    req, _ := http.NewRequest("POST", "https://api.renderscreenshot.com/v1/screenshot", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()
    out, _ := io.ReadAll(res.Body)

    if res.StatusCode == http.StatusOK {
        if err := os.WriteFile("pricing.png", out, 0o644); err != nil {
            log.Fatal(err)
        }
        return
    }
    var e struct {
        Error struct{ Message string } `json:"error"`
    }
    json.Unmarshal(out, &e)
    if res.StatusCode == 422 && strings.Contains(e.Error.Message, "rs-guard") {
        log.Fatal("Blocked or challenged: check the Cloudflare skip rule")
    }
    log.Fatalf("%d %s", res.StatusCode, e.Error.Message)
}

Java

// Capture.java - Java 15+, java.net.http only (not run for this guide)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

public class Capture {
    public static void main(String[] args) throws Exception {
        String ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
            + "Chrome/140.0.0.0 Safari/537.36 SiteCapture/" + System.getenv("CAPTURE_TOKEN");
        String body = """
            {"url": "https://www.example.com/pricing", "preset": "og_card",
             "browser": {"user_agent": "%s"},
             "page": {"scripts": ["if (!document.querySelector('#app')) throw new Error('rs-guard: page content missing')"]}}
            """.formatted(ua); // Java 15+ text block; build the JSON with your JSON library on Java 11

        HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot"))
            .timeout(Duration.ofSeconds(90))
            .header("Authorization", "Bearer " + System.getenv("RS_API_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<byte[]> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofByteArray());

        if (res.statusCode() == 200) {
            Files.write(Path.of("pricing.png"), res.body());
            return;
        }
        String error = new String(res.body());
        boolean blocked = res.statusCode() == 422 && error.contains("rs-guard");
        System.err.println(blocked ? "Blocked or challenged: check the Cloudflare skip rule" : res.statusCode() + " " + error);
        System.exit(1);
    }
}

Step 3: Add the skip rule in Cloudflare

In the Cloudflare dashboard, open your domain, go to Security rules, and select Create rule → Custom rules.

  1. Name the rule, for example Allow RenderScreenshot captures.
  2. Select Edit expression and enter an expression that matches the token. Scope it to the paths you capture:
   (http.user_agent contains "SiteCapture/3f9c1a7be2d64c0e8a51f7b2c9d0e4a6"
    and not starts_with(http.request.uri.path, "/admin"))
  1. Under Then take action, choose Skip, and select what your zone uses: All remaining custom rules, All Super Bot Fight Mode rules, All managed rules and All rate limiting rules. Under the products to skip, select Browser Integrity Check and Security Level if they are on.
  2. Under Place at, choose First, so the skip runs before any rule that challenges.
  3. Select Deploy.

Now run the request from Step 2 again. Then check Security → Events in Cloudflare: the request should show your rule with the action Skip. If you still see Managed Challenge or Block, open the event and read which service acted. If it says Bot Fight Mode, no rule can skip it; see the plan table above.

Keep the token safe

A user-agent token is a shared secret that travels in plain sight of every system that logs requests: Cloudflare's logs, your origin's access logs and any monitoring in between. Treat it like a password:

  • Keep it in server-side configuration only. Never put it in front-end code. A signed screenshot URL cannot carry it, because the user agent is a POST-only option.
  • Scope the rule to the pages you capture. Keep login, admin and API paths out of it.
  • Rotate it: add the new token to the rule with or, deploy your app with the new value, then remove the old one.
  • Keep rate limiting for everything else; the skip applies only to matching requests.

Other ways in

  • Render the HTML yourself. If you can produce the page's HTML on your server, send it in the html parameter. The renderer never requests the protected URL, but assets it references are still fetched from your site, so scope the skip rule to static paths or inline the assets.
  • A capture-only hostname. Serve the same pages on a hostname without the bot rules (for example, capture.example.com behind an Access policy or an unlisted origin), and capture that.
  • Enterprise Bot Management. The Browser Run detection ID 119853733 identifies the platform, not RenderScreenshot. Combine it with the token, never use it alone.

Troubleshooting

Symptom Cause Fix
Image shows "Just a moment..." Challenge page rendered, no guard Add the page.scripts guard and the skip rule
422 with rs-guard in the message Guard found no #app: still blocked, or wrong selector Check Security → Events; check the selector on your real page
Event shows Bot Fight Mode Free plan bot feature, cannot be skipped Turn it off or move to Super Bot Fight Mode
Rule never matches Token typo, or the rule checks the full user agent with eq Use contains on the token only
Works for HTML, images missing Assets on another hostname are still challenged Add that hostname to the rule, or skip static paths
422 screenshot_failed with a timeout Challenge loop kept the page busy Same fix as a blocked capture; then check wait settings
Some captures pass, some fail Rate limiting rule not skipped Add All rate limiting rules to the skip

Related in this topic

Was this page helpful?