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.
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:
- The canonical query string and the HMAC. Pure computation. Easy anywhere.
- 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. - 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.escapedoes (onlyA-Z a-z 0-9 - _ . ~stay literal, a space becomes+), - sorts the
name=valuepairs 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;
URLSearchParamsiterates 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.
immutablecaching 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 sendimmutable.
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:
- Rejection rate: count
expired_signatureandinvalid_signatureresponses in your request logs as a share of signed requests. That share is the most origin traffic an edge verifier can remove. - Repeat rate: count requests whose parameters hash to an earlier request inside the TTL. Our responses carry
X-Cache-Hit, so withcache.availableyou can read this straight from the headers. - Embed share: how many image loads use the
X-Cache-URLlink 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
- How We Built a Screenshot API: Rails, Workers and R2 The architecture of RenderScreenshot: a Rails 8 API, a Cloudflare Worker that drives the browser, R2 storage, and the bugs and fixes that shaped it.
- Cloudflare Browser Rendering API: A Tested Tutorial (2026) Screenshots with Cloudflare Browser Rendering (now Browser Run): REST Quick Actions, the Workers binding, session reuse, limits and cost. Tested code.
- Screenshot API vs Self-Hosted Puppeteer: Real Cost A build-vs-buy cost model for screenshots: servers, Cloudflare browser time, engineering hours and API plans, with a calculator and a capacity script.
- Which Websites Block Headless Chrome? A 200-Site Study We loaded the top 200 Tranco domains in three headless Chrome profiles. 15% of sites blocked or challenged default Puppeteer. Raw data and script.
- How Long Does a Screenshot Take? Latency, Phase by Phase Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.
- Blocking Cookie Banners Automatically: How It Fails How our block.cookie_banners option clicks or hides consent banners in headless Chrome, the code behind it, and eight fixtures that show where it breaks.