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: Bearer header. 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

  1. Create a new key in the dashboard under API Keys, with a name that says what it is for.
  2. Deploy the new key to the service's environment.
  3. Check in the dashboard that the new key shows recent use and the old key does not.
  4. 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;
  • localhost and host names ending in .local, .internal, .localhost, .lan, .intranet and .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:

  1. 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.
  2. 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.
  3. Parameter pass-through. Forwarding the client's JSON unchanged lets the client add cookies, headers, scripts or html to 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-Id and 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 in this topic

Was this page helpful?