Cloudflare Workers Screenshots: Sign at the Edge, Cache

A Cloudflare Worker that signs screenshot URLs with Web Crypto, caches images with the Cache API and serves og:image cards from your own domain.

By Simon Chiu · Updated

This page gives you a Cloudflare Worker that serves a screenshot of any page of your site at https://your-site/og/<path>. It signs the RenderScreenshot URL inside the Worker with Web Crypto, fetches the image, and keeps it in Cloudflare's cache with the Cache API. Crawlers and browsers get the image from your own domain, and most requests never reach our API. It is part of our integrations hub.

We know this path from both sides. Our own renderer runs on Cloudflare: a rendering Worker drives the browser through Browser Rendering and writes each image to R2, and cdn.renderscreenshot.com serves the files from R2. The gateway post explains which checks run where. This page is the customer side: your Worker in front of our API.

Last tested: 2026-10-18 with wrangler 4.151.1 (wrangler dev, local workerd) and Node 22.18. The Worker called a local stub of our API that checks signatures with the documented algorithm and the published test key pair. Results: the first /og/ request rendered, the second and a ?v=2 variant were Cache API hits, /og//evil.example/x returned 404, a failing page redirected to the fallback image with no-store, and redirect mode returned a URL that the stub accepted. We did not deploy it to a Cloudflare zone for this page, so it has no dashboard screenshots.

What you can build

  • og:image for every page without a build step. Put <meta property="og:image" content="https://example.com/og/blog/hello-world"> in your layout. The Worker captures https://example.com/blog/hello-world with the og_card preset.
  • Thumbnails of your own pages for a site search, a docs index or an internal dashboard, served from your domain.
  • A cheap front for a busy page. A popular link shared in many chats asks for the same image again and again. The edge answers those requests.

If your site runs on Cloudflare Pages and you want the middleware to write the meta tags as well, use the Pages Function version instead.

Prerequisites

Step by step

  1. Create the project files below: src/index.js and wrangler.jsonc.
  2. Store the keys as secrets: npx wrangler secret put RS_PUBLIC_KEY_ID, then npx wrangler secret put RS_SECRET_KEY. For wrangler dev, put the same two names in .dev.vars.
  3. Set SITE_ORIGIN and the route to your domain, then run npx wrangler deploy.
  4. Open https://your-site/og/ and check the CF-Cache-Status header on the second request.
// Cloudflare Worker: /og/<path> returns a screenshot of SITE_ORIGIN/<path>.
// The Worker signs a RenderScreenshot URL with Web Crypto and caches the image with the Cache API.
// Secrets: RS_PUBLIC_KEY_ID (rs_pub_...), RS_SECRET_KEY (rs_secret_...). Vars: SITE_ORIGIN, MODE.
const API = 'https://api.renderscreenshot.com/v1/screenshot';
const EDGE_TTL = 86400; // seconds the edge keeps an image

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (request.method !== 'GET' || !url.pathname.startsWith('/og/')) {
      return new Response('Not found', { status: 404 });
    }

    // Only pages of your own site, so nobody can spend your credits on other sites.
    const path = url.pathname.slice('/og'.length); // "/og/blog/post" -> "/blog/post"
    const target = new URL(path, env.SITE_ORIGIN);
    // "/og//evil.example" would resolve to another host. Refuse anything off your origin.
    if (target.origin !== new URL(env.SITE_ORIGIN).origin) return new Response('Not found', { status: 404 });
    const params = { url: target.toString(), preset: 'og_card', cache_available: 'true', cache_ttl: '2592000' };

    if (env.MODE === 'redirect') {
      // The browser or crawler fetches the image from the API itself.
      // One expiry per day keeps the redirect target stable, so it can be cached for an hour.
      const day = 86400;
      const expires = Math.floor(Date.now() / 1000 / day) * day + 7 * day;
      const signed = await signedUrl(params, env, expires);
      return new Response(null, { status: 302, headers: { Location: signed, 'Cache-Control': 'public, max-age=3600' } });
    }

    // Proxy mode. The cache key ignores the query string, so ?v=1, ?v=2 ... cannot bypass the cache.
    const cache = caches.default;
    const cacheKey = new Request(url.origin + url.pathname, { method: 'GET' });
    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    const signed = await signedUrl(params, env, Math.floor(Date.now() / 1000) + 300); // used now, so 5 minutes
    const upstream = await fetch(signed);

    if (!upstream.ok) {
      const body = await upstream.json().catch(() => ({}));
      console.log(`render failed ${upstream.status} ${body.error?.code} ${target}`);
      // Do not cache failures. Send crawlers to a static fallback image instead of an error.
      if (env.FALLBACK_IMAGE) {
        return new Response(null, { status: 302, headers: { Location: env.FALLBACK_IMAGE, 'Cache-Control': 'no-store' } });
      }
      return new Response('Screenshot unavailable', { status: 502, headers: { 'Cache-Control': 'no-store' } });
    }

    const headers = new Headers({
      'Content-Type': upstream.headers.get('Content-Type') || 'image/png',
      'Cache-Control': `public, max-age=${EDGE_TTL}`,
    });
    const title = upstream.headers.get('X-Screenshot-Title');
    if (title) headers.set('X-Screenshot-Title', title);

    const response = new Response(upstream.body, { status: 200, headers });
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

// Encode like the API: space -> "+", only A-Z a-z 0-9 - _ . ~ stay as they are.
function encode(value) {
  return encodeURIComponent(String(value))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');
}

async function signedUrl(params, env, expires) {
  const fields = { ...params, key_id: env.RS_PUBLIC_KEY_ID, expires: String(expires) };
  const query = Object.entries(fields)
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join('&');
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(env.RS_SECRET_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(query));
  const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  return `${API}?${query}&signature=${hex}`;
}
{
  "name": "og-screenshots",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "vars": {
    "SITE_ORIGIN": "https://example.com",
    "MODE": "proxy",
    "FALLBACK_IMAGE": "https://example.com/og-default.png"
  },
  "routes": [{ "pattern": "example.com/og/*", "zone_name": "example.com" }]
}

How the Worker decides

flowchart TD
  R[GET /og/path] --> O{Target on SITE_ORIGIN?}
  O -- no --> N[404]
  O -- yes --> M{MODE}
  M -- redirect --> S[302 to signed API URL]
  M -- proxy --> C{Cache API hit?}
  C -- yes --> H[Return cached image]
  C -- no --> F[Sign, fetch API]
  F -- 200 --> P[Return image, cache.put in waitUntil]
  F -- error --> B[302 to FALLBACK_IMAGE, no-store]

Three choices matter more than the rest:

  • Only your origin. Without the origin check, /og//evil.example/x resolves to another host, and anyone could spend your credits on any site. The Worker compares the origin after it parses the URL.
  • The cache key drops the query string. Otherwise ?v=1, ?v=2 and so on would each miss the cache and each cost an API request.
  • Failures are never cached. A timeout today should not become a broken card for a day. The fallback redirect carries no-store.

Proxy mode or redirect mode

MODE: "proxy" (default) MODE: "redirect"
Who calls the API The Worker The browser or crawler, after a 302
Image URL that crawlers see Your domain Your domain, then api.renderscreenshot.com
Edge cache Cache API, per data center None in the Worker. Clients may keep the 302 for one hour
Requests to the API At most one per page, per data center, per EDGE_TTL One per fetch that reaches the API
Signed URL expiry 5 minutes (used at once) 7 days, rounded to the day so the URL stays stable

Use proxy mode on a custom domain or route. Cloudflare's Cache API docs say that cache operations work there, and have no effect in the dashboard editor and the Playground. Redirect mode is for places where you cannot cache, such as previews. Its requests count against your account's per-minute limit, but with cache_available=true repeat renders use 0 credits.

Cloudflare's docs (checked October 2026) also describe Workers Caching, a newer cache that sits in front of the Worker, is tiered and collapses concurrent requests. The Cache API used here is local to each data center. For high traffic, read both pages before you choose.

Parameter mapping

Worker setting API parameter Value
SITE_ORIGIN + path after /og url https://example.com/blog/hello-world
(code) preset og_card, 1200 × 630 PNG. See presets.
(code) cache_available, cache_ttl true, 2592000: reuse a render for up to 30 days
RS_PUBLIC_KEY_ID key_id rs_pub_...
(code) expires Unix seconds, at most 30 days ahead
RS_SECRET_KEY signs signature Never sent
EDGE_TTL none How long your zone keeps the image

To add options, put more flat GET parameters in params, for example block_cookies: 'true'. Add them before signing. A parameter added after signing fails with invalid_signature.

Errors and retries

The Worker does not retry inside the request, because a crawler waits for the answer. Failures are not cached, so the next request for the same card tries again. In wrangler tail, each failure logs one line with the status, the error.code and the target.

Logged code Meaning Fix
invalid_signature The string to sign differs from the server's Do not change encode(). Spaces must become +.
invalid_expiration expires is in milliseconds or more than 30 days ahead Use Unix seconds
missing_credentials Unknown or revoked rs_pub_ key Check the secret, or create a new pair
too_many_requests The account used its per-minute limit Lower traffic to the API: keep proxy mode and a long EDGE_TTL
timeout The page did not load in 30 seconds Fix the page, or add timeout: '45' to params

Limits and costs

  • Credits. A miss in a new data center calls the API. With cache_available, only the first render of a page in 30 days uses a credit. The free plan gives 150 credits each month. See pricing.
  • Rate limit. Each call to the API counts toward your account's per-minute limit, 10 on the free plan. Cache hits in your zone do not reach us, so they do not count. See rate limits.
  • Workers. Time spent waiting on fetch() is not CPU time. The work in the Worker is one HMAC and a cache lookup.

Troubleshooting

  • CF-Cache-Status never shows HIT. You are on a preview or dashboard editor, or the route is not on your zone. Test on the real route.
  • Every request returns the fallback. Run npx wrangler tail and read the logged code.
  • The card shows an old version of the page. Both caches hold it: your zone for EDGE_TTL, and our API for cache_ttl. Purge the URL in your zone, and send one request with cache_refresh: 'true' signed in, or purge the render.

Frequently asked questions

Can a Cloudflare Worker take a screenshot of a web page?
A Worker can drive a browser through Cloudflare's own Browser Rendering, or it can call a screenshot API with fetch(). This page does the second: the Worker signs a RenderScreenshot URL with Web Crypto, fetches the image and caches it at the edge.
Why sign a URL inside the Worker instead of sending an API key?
A signing key pair can only authenticate GET requests for screenshots. It cannot run batches, purge the cache or read usage. If the Worker's secret leaks, the damage is smaller than with a full rs_live_ key.
Does every og:image fetch cost a credit?
No. Images that the Worker cached are served without an API call. On a miss, the signed URL includes cache_available=true, so a page rendered in the last 30 days comes back for 0 credits.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.