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.
- Name the rule, for example
Allow RenderScreenshot captures. - 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"))
- 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.
- Under Place at, choose First, so the skip runs before any rule that challenges.
- 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
htmlparameter. 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.combehind an Access policy or an unlisted origin), and capture that. - Enterprise Bot Management. The Browser Run detection ID
119853733identifies 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
- Bot protection and CAPTCHAs: what to expect on sites you do not own.
- Custom user agent: other uses of
browser.user_agent. - Page modifications:
page.scripts,hideandremove. - Error handling and retries: retrying
422responses that are not blocks.