Security best practices
A screenshot API integration touches three security surfaces: the keys that pay for renders, the public URLs you put in pages, and any endpoint of yours that accepts a URL from a user and passes it on. This guide covers each one, with code you can copy. It is part of the authentication guides.
Keys: where each one lives
| Key | Store it in | Never put it in |
|---|---|---|
rs_live_ API key |
Environment variable or secret manager on your servers and CI | Frontend code, mobile apps, query strings in public pages, Git |
rs_secret_ signing key |
Same as above, only on the service that signs URLs | Anywhere a browser can read it; it is never sent to the API either |
rs_pub_ key ID |
Configuration; it appears in every signed URL | Nothing to protect, but keep it next to its secret so pairs do not get mixed up |
New keys are created in the dashboard after you sign up. Practical rules:
- Send the key in the
Authorization: Bearerheader. The?api_key=query parameter works, but URLs end up in server logs, proxy logs, browser history and analytics. Keep it for quick tests in a terminal. - Use one key per service and environment. An account can hold several keys. Name them (
web-prod,worker-prod,ci) so that the dashboard shows which one was used last and you can revoke one without touching the others. - Fail at start-up when the key is missing. A missing key that only shows up as a 401 in production is harder to diagnose than a crash at boot. The SDK examples below do this.
- Scan for leaked keys. Keys are the prefix followed by 32 letters and digits, so a simple pattern finds them in a repository:
# Finds live and secret keys in tracked files (rs_pub_ IDs are public and not matched) git grep -nE 'rs_(live|secret)_[A-Za-z0-9]{32}'
Tested on 2026-10-15 with Git: the pattern matched a 32-character rs_live_ and rs_secret_ value and skipped rs_pub_ IDs and placeholders such as rs_live_xxxxx. Add the same pattern to the secret scanner you use in CI.
Rotating a key without downtime
- Create a new key in the dashboard under API Keys, with a name that says what it is for.
- Deploy the new key to the service's environment.
- Check in the dashboard that the new key shows recent use and the old key does not.
- Revoke the old key. Requests that still use it fail with
revoked_key(HTTP 401), which your monitoring should report.
When you think a key has leaked, do step 4 first, then steps 1 to 3. A short outage is cheaper than credits spent by someone else.
Signed URLs: choosing an expiry
Signed URLs let you put screenshots in public HTML, as explained in publishable keys. The signature covers every parameter, so a visitor cannot change the target or the options. The remaining risk is time: a signed URL works for anyone until it expires, and expires can be at most 30 days ahead.
| Where the URL appears | Suggested lifetime | Reason |
|---|---|---|
og:image and other social meta tags |
7 to 30 days | Platforms fetch the image again when a link is shared later |
| HTML pages that you cache | Longer than the page cache | A cached page must never contain an expired URL |
| Emails | 30 days, or a stored X-Cache-URL instead |
Emails are opened long after sending |
| Internal tools behind a login | Hours | The page is rebuilt on every visit anyway |
Keep the URL stable. If you sign with "now plus seven days", every page render produces a new URL, and social platforms and browsers treat each one as a new image. Round the expiry up to a day boundary instead:
// Expiry that changes once a day, so the same page gets the same signed URL all day. const DAY = 86_400; export function bucketedExpiry(nowSeconds = Math.floor(Date.now() / 1000), minTtl = 7 * DAY) { // Round up to the next midnight UTC that is at least `minTtl` away. return Math.ceil((nowSeconds + minTtl) / DAY) * DAY; }
The result is between 7 and 8 days ahead and identical for every request on the same day. Keep minTtl at 29 days or less so the rounded value stays inside the 30-day limit. Tested on 2026-10-15 with Node 22.18.
SSRF: what the renderer already blocks
Server-side request forgery (SSRF) is the risk that a service which fetches URLs can be pointed at addresses only it can reach, such as a cloud metadata endpoint or an admin panel on a private network. A screenshot service fetches URLs for a living, so we built the checks into the renderer. It refuses:
- non-HTTP schemes, and URLs with a user name or password in them;
- ports other than 80 and 443;
localhostand host names ending in.local,.internal,.localhost,.lan,.intranetand.home.arpa;- private, loopback, link-local (including
169.254.169.254), carrier-grade NAT, multicast and reserved IPv4 ranges, and the IPv6 equivalents.
It checks the DNS answers for a host name before navigation, and it checks the address the browser actually connected to for every response, so a DNS answer that changes between the check and the request (DNS rebinding) or a redirect into private space also fails. A refused target returns a target_error and no image.
SSRF and open proxies in your own endpoints
Most integrations add an endpoint such as /screenshot?url=... on their own site. The renderer's checks protect our side; your endpoint needs its own rules, for three reasons:
- Open proxy. An endpoint that forwards any URL lets anyone spend your credits and lets them use your domain to screenshot content you would not want associated with it.
- Your own fetches. If your endpoint downloads something itself, for example the image from a URL the client sent back, that fetch runs inside your network, without our protections.
- Parameter pass-through. Forwarding the client's JSON unchanged lets the client add cookies, headers, scripts or
htmlto your request.
Validate the target against an allowlist before you call the API:
// target-guard.mjs: decide whether your endpoint may ask for a screenshot of `input`. const ALLOWED_HOSTS = new Set(['example.com', 'www.example.com']); // your own sites only export function checkTarget(input) { let url; try { url = new URL(input); } catch { return { ok: false, reason: 'not a URL' }; } if (url.protocol !== 'https:') return { ok: false, reason: 'https only' }; if (url.username || url.password) return { ok: false, reason: 'credentials in URL' }; if (url.port !== '') return { ok: false, reason: 'non-default port' }; // The URL parser lowercases the host; strip a trailing dot so "example.com." matches. const host = url.hostname.replace(/\.$/, ''); if (!ALLOWED_HOSTS.has(host)) return { ok: false, reason: `host ${host} not allowed` }; url.hostname = host; return { ok: true, url: url.toString() }; }
Tested on 2026-10-15 with Node 22.18 against 12 inputs. Allowed: https://example.com/pricing, https://EXAMPLE.com./x (normalized to https://example.com/x) and https://www.example.com/a?b=c. Rejected: http:// URLs, a :8443 port, user:pw@ credentials, https://[email protected]/, https://example.com.evil.net/, 169.254.169.254, localhost, javascript: and a protocol-relative //example.com.
Then build the API request on the server from fixed options, and copy across only the fields you mean to expose:
const ALLOWED_PRESETS = new Set(['og_card', 'twitter_card', 'mobile']); export function buildRequest(clientInput) { const target = checkTarget(clientInput.url); if (!target.ok) throw new Error(target.reason); return { url: target.url, preset: ALLOWED_PRESETS.has(clientInput.preset) ? clientInput.preset : 'og_card', block: { ads: true, trackers: true }, // No network, page, html or storage fields from the client, ever. }; }
Add the usual protections to the route as well: authentication if the feature is for logged-in users, a per-user rate limit, and a cap on the number of distinct targets per user per day.
If users can supply the content of an html template, escape every value and keep user input out of <script> and attribute contexts. The HTML to image guide has a tested escaping helper.
Other surfaces
- Webhooks. Verify the signature of every webhook before you trust its body; see webhook verification.
- Your own storage. If screenshots upload to your bucket, give the credentials write access to that bucket only. The storage parameters page lists what is needed.
- Captured content. A screenshot of a third-party page is untrusted input. If an AI model reads it, text in the image can try to give the model instructions. See prompt injection in screenshots.
- Logs. Log
X-Request-Idand the target URL. Do not log request headers, which contain the key.
SDK examples: load the key safely
Each example reads the key from the environment and stops at once when it is missing.
Node.js
import { Client } from 'renderscreenshot'; const apiKey = process.env.RS_API_KEY; if (!apiKey) throw new Error('RS_API_KEY is not set'); export const client = new Client(apiKey);
Python
import os from renderscreenshot import Client api_key = os.environ.get('RS_API_KEY') if not api_key: raise RuntimeError('RS_API_KEY is not set') client = Client(api_key)
Ruby
require 'renderscreenshot' # KeyError at boot if the variable is missing CLIENT = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
PHP
use RenderScreenshot\Client; $apiKey = getenv('RS_API_KEY'); if ($apiKey === false || $apiKey === '') { throw new RuntimeException('RS_API_KEY is not set'); } $client = new Client($apiKey);
Java
String apiKey = System.getenv("RS_API_KEY"); if (apiKey == null || apiKey.isBlank()) { throw new IllegalStateException("RS_API_KEY is not set"); } Client client = new Client(apiKey);
Go
apiKey := os.Getenv("RS_API_KEY") if apiKey == "" { log.Fatal("RS_API_KEY is not set") } client, err := rs.New(apiKey) if err != nil { log.Fatal(err) }
Checklist
| Check | Symptom if missed | Fix |
|---|---|---|
No rs_live_ or rs_secret_ value in frontend code or Git |
Unknown usage in the dashboard | Revoke, rotate, add the scan pattern to CI |
| Header authentication on servers | Keys in access logs | Move from ?api_key= to Authorization: Bearer |
| One key per service and environment | Cannot revoke one service without breaking others | Create named keys |
| Signed URL lifetime longer than page cache | Broken images on cached pages | Raise the TTL or shorten the cache |
Stable expires values |
Every page view creates a new image URL | Round the expiry to a day boundary |
| Allowlisted targets in your endpoints | Credits spent on unknown sites | Validate with checkTarget |
| Fixed API options on the server | Clients add cookies, scripts or HTML | Build the request from an allowlist of fields |
| Webhook signatures verified | Forged webhook calls accepted | Verify before parsing |
Related
- Publishable keys for frontend patterns and the signing algorithm.
- Signed URLs and API keys.
- Error handling for
revoked_key,invalid_signatureandexpired_signature.