· Updated · Engineering · By Simon Chiu

Cloudflare Workers as an API Gateway for Screenshots

What runs at the edge in our screenshot API today, what stays in Rails, and tested Worker code to verify HMAC-signed URLs with Web Crypto before the origin.

Line illustration of the RenderScreenshot mascot guarding a gate under a cloud where requests pass to a browser

A Cloudflare Worker makes a good API gateway when it can reject or answer a request without asking your origin. For a screenshot API, that means two jobs: checking signed URLs, and serving images that already exist. This post describes what RenderScreenshot runs at the edge today, what deliberately stays in our Rails origin and why, and a tested Worker function that verifies our signed URLs with Web Crypto, so you can see what moving the check to the edge would take. It is part of how we built RenderScreenshot.

Code excerpts from our repositories as of October 15, 2026. The verifier below was tested on that date against signatures produced by Ruby's OpenSSL::HMAC and ActiveSupport's to_query.

What runs where today

The honest starting point: our signed URLs are verified in Rails, not in a Worker. The edge does three other things.

flowchart LR
  U[Browser, crawler or SDK] -->|GET /v1/screenshot?…&signature=…| O[Rails API origin<br/>verify signature, credits,<br/>cache index, logging]
  O -->|miss: POST + X-Worker-Secret| RW[Rendering Worker<br/>SSRF checks, Browser Run]
  RW --> R2[(R2 bucket)]
  U -->|X-Cache-URL link| CDN[cdn.renderscreenshot.com<br/>R2 public bucket]
  CDN --> R2
  H[Custom hostname] --> PW[Proxy Worker<br/>KV hostname lookup,<br/>account-scoped keys]
  PW --> R2
Edge component Job Talks to the origin?
R2 bucket on cdn.renderscreenshot.com Serves every rendered image by key. This is the X-Cache-URL link No
Rendering Worker Validates the target URL, drives the browser, writes to R2. Accepts requests only with the shared X-Worker-Secret Only Rails calls it
Proxy Worker for custom hostnames Maps a hostname to one account and serves only that account's objects. Built and deployed, not yet a customer feature No

So the gateway pattern is already half there: the most frequent request, fetching an existing image, never touches Rails, because customers embed the CDN link. What still goes through the origin is every GET /v1/screenshot, including signed URLs in og:image tags that crawlers fetch over and over.

Why the signature check is in Rails

The verifier needs three things, and only one of them is easy at the edge:

  1. The canonical query string and the HMAC. Pure computation. Easy anywhere.
  2. The secret for the rs_pub_ key ID. Secrets live encrypted in PostgreSQL. An edge copy means replicating secrets into Workers KV or another store, and keeping revocation in sync.
  3. What happens after a valid signature: credit check, cache lookup, request log, render. All of it uses the database of record.

Moving step 1 to the edge saves the origin only from invalid signed URLs: expired links in old og:image tags, tampered parameters, scrapers that change width= and hope. Valid requests still need the origin for billing. Whether that is worth a replicated secret store depends on how much bad traffic you get. Below is what the edge half looks like, so you can judge.

The edge verifier

The function has to reproduce the server's canonical string exactly. Rails builds it with request.query_parameters.except('signature').to_query, which:

  • decodes the query (a + becomes a space),
  • re-encodes every name and value the way Ruby's CGI.escape does (only A-Z a-z 0-9 - _ . ~ stay literal, a space becomes +),
  • sorts the name=value pairs as plain strings,
  • joins them with &.

URLSearchParams does the decoding the same way. JavaScript's encodeURIComponent leaves ! ' ( ) * alone and writes a space as %20, so both need patching:

// edge-verify.mjs: verify a RenderScreenshot-style signed URL with Web Crypto only,
// so the same function runs in a Cloudflare Worker, Deno, Bun or Node 22+.

// Encode like Ruby's CGI.escape (what Rails' Hash#to_query uses):
// keep A-Z a-z 0-9 - _ . ~, space becomes "+", everything else %XX uppercase.
function formEncode(value) {
  return encodeURIComponent(value)
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');
}

function timingSafeEqualHex(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

export async function verifySignedUrl(requestUrl, lookupSecret, now = Date.now()) {
  const params = new URL(requestUrl).searchParams; // decodes "+" to space, like Rails
  const keyId = params.get('key_id');
  const expires = Number(params.get('expires'));
  const signature = params.get('signature');
  if (!keyId?.startsWith('rs_pub_') || !signature || !Number.isInteger(expires)) {
    return { ok: false, code: 'missing_credentials' };
  }
  if (expires * 1000 < now) return { ok: false, code: 'expired_signature' };

  const seen = new Set();
  const pairs = [];
  for (const [name, value] of params) {
    if (name === 'signature') continue;
    if (seen.has(name)) return { ok: false, code: 'invalid_signature' }; // ambiguous query
    seen.add(name);
    pairs.push(`${formEncode(name)}=${formEncode(value)}`);
  }
  const canonical = pairs.sort().join('&');

  const secret = await lookupSecret(keyId); // e.g. a KV read; never ship secrets in code
  if (!secret) return { ok: false, code: 'invalid_api_key' };
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(canonical)));
  const expected = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('');
  return timingSafeEqualHex(expected, signature.toLowerCase()) ? { ok: true, keyId } : { ok: false, code: 'invalid_signature' };
}

Three details are easy to get wrong:

  • Duplicate parameters. Rails keeps the last value of a repeated plain key; URLSearchParams iterates all of them. Rather than copy that behaviour, the edge rejects repeated names outright. A signer never produces them, so only a crafted URL is affected.
  • Constant-time comparison. Workers also offer crypto.subtle.timingSafeEqual; the loop above keeps the function portable to Node and Deno for testing.
  • Error codes. The function returns the same codes our API documents (expired_signature, invalid_signature), so a client cannot tell whether the edge or the origin rejected it. Keep that property if you split a check across two layers.

How we tested it

The test generates signed URLs in Ruby with the same two library calls Rails uses, then verifies them with the function above:

import { execFileSync } from 'node:child_process';
import { verifySignedUrl } from './edge-verify.mjs';

const secret = 'rs_secret_' + 'z'.repeat(32);
const cases = [
  { url: 'https://example.com', preset: 'og_card' },
  { url: 'https://example.com/search?q=dark mode&lang=fr', width: '1200', format: 'webp' },
  { url: "https://例え.jp/パス?a=~*'()!+%2F", preset: 'og_card' },
];
let failures = 0;
for (const fields of cases) {
  const all = { ...fields, key_id: 'rs_pub_' + 'p'.repeat(32), expires: '1893456000' };
  const ruby = `require "json"; require "openssl"; require "active_support"; require "active_support/core_ext/object/to_query";
    h = JSON.parse(ARGV[0]); q = h.to_query; print q + "&signature=" + OpenSSL::HMAC.hexdigest("SHA256", ARGV[1], q)`;
  const query = execFileSync('ruby', ['-e', ruby, JSON.stringify(all), secret], { encoding: 'utf8' });
  const good = await verifySignedUrl(`https://api.example/v1/screenshot?${query}`, async () => secret);
  const tampered = await verifySignedUrl(`https://api.example/v1/screenshot?${query.replace('og_card', 'og_cardx').replace('1200', '1201')}`, async () => secret);
  console.log(good.ok ? 'PASS' : 'FAIL', 'valid |', tampered.ok ? 'FAIL' : 'PASS', 'tampered ->', tampered.code);
  if (!good.ok || tampered.ok) failures++;
}
process.exitCode = failures ? 1 : 0;

Tested on October 15, 2026 with Node.js 22.18.0, Ruby 4.0.7 and ActiveSupport. All three valid URLs passed, all three tampered URLs returned invalid_signature, and an expired URL returned expired_signature. The third case, with non-ASCII characters and the reserved characters ~*'()!+ and an encoded slash, is the one that catches encoding mistakes. If your signer passes that case, it will pass the others.

Wiring it into a gateway Worker

A gateway Worker in front of the origin would verify first and forward only what passed. In outline:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.searchParams.has('signature')) {
      const result = await verifySignedUrl(request.url, (keyId) => env.SIGNING_SECRETS.get(keyId));
      if (!result.ok) {
        return Response.json(
          { error: { type: 'authentication_error', code: result.code, retryable: false } },
          { status: 401 },
        );
      }
    }
    return fetch(request); // pass through to the origin, which still owns credits and logging
  },
};

This is a fragment, not something we run: SIGNING_SECRETS is a KV namespace you would have to populate from the origin. Note that the origin still re-verifies. The edge check is a filter, not the source of truth, so a bug in it can only reject good traffic, never admit bad traffic.

The open questions are operational, not cryptographic:

Question Why it matters Our current answer
How fast does a revoked key stop working at the edge? Workers KV is eventually consistent; a revoked secret can still verify for a short time Acceptable only because the origin re-checks
Where do edge rejections get logged? Customers debug failed og:image links from our request logs Would need a log pipeline, for example a Queue to the origin
Who owns the encoding rules? The edge and Rails must build byte-identical strings One shared test, as above, run in both repositories

Serving cached renders at the edge

The second gateway job is answering repeat requests without the origin. We do half of it today: the X-Cache-URL link points straight at R2, so an image embedded by its CDN URL is served by Cloudflare with no origin call at all. The other half, answering a repeat GET /v1/screenshot at the edge, is harder than it looks for one reason: our cache lookup is per account and returns zero-credit hits only when the client asks for them (cache.available). The cache index is a query over our request log in PostgreSQL. An edge cache keyed only on the URL would serve one account's render to another, which our design rules out on purpose.

The proxy Worker we built for custom hostnames shows the pattern that does work at the edge: scope everything to one account before touching storage.

const record = await env.CUSTOM_CDN_DOMAINS.get(hostname, { type: 'json' });
if (!record || !record.verified) return new Response('Not Found', { status: 404 });

const objectKey = url.pathname.replace(/^\//, '');
if (!objectKey.startsWith(`accounts/${record.account_id}/`)) {
  return new Response('Not Found', { status: 404 }); // never serve another account's object
}

const object = await env.R2_BUCKET.get(objectKey);
if (!object) return new Response('Not Found', { status: 404 });

const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('cache-control', 'public, max-age=31536000, immutable');
return new Response(object.body, { headers });

Two choices in that code generalize:

  • Every miss is a 404, never a 403. The response does not reveal whether an object exists under another account.
  • immutable caching is safe only if object keys are never reused. Our rendering Worker writes a new key with a timestamp for every render, so a key always means the same bytes. If your keys can be overwritten, do not send immutable.

How long a cached render should live, and how to purge it, is a separate topic: see edge caching for screenshots and the cache management endpoints.

How to measure whether a gateway would pay off

We have not published hit-rate or rejection-rate numbers, and we will not estimate them here. You can measure both for your own traffic before building anything:

  1. Rejection rate: count expired_signature and invalid_signature responses in your request logs as a share of signed requests. That share is the most origin traffic an edge verifier can remove.
  2. Repeat rate: count requests whose parameters hash to an earlier request inside the TTL. Our responses carry X-Cache-Hit, so with cache.available you can read this straight from the headers.
  3. Embed share: how many image loads use the X-Cache-URL link rather than the API URL. Those already bypass the origin.

If the first number is small and the third is large, the edge already does most of the gateway's job, which is where we are.

Where to go from here

  • Signed URL design: why the scheme uses a key pair, a canonical string and required expiry.
  • Signed URL reference: signing code in six languages.
  • Cloudflare Workers integration: calling the screenshot API from your own Worker.

To try signed URLs end to end, create a free account and ask support for a signing key pair, as described in the reference.

Related in this topic