· Updated · Engineering · By Simon Chiu

Signed URL Design: HMAC, Expiry and Canonical Params

How we designed signed screenshot URLs: an HMAC-SHA256 key pair, a canonical query string, required expiry, and tested signing code in Node, Python and Ruby.

Line illustration of the RenderScreenshot mascot holding a key next to a chain with a padlock and an hourglass

A signed URL lets a browser or crawler fetch a screenshot without an API key. You sign the request on your server with a secret, put the URL in an og:image tag or an <img src>, and anyone can load it until it expires. Nobody can change it. This post explains how we designed RenderScreenshot's signed URLs: the key pair, the canonical string, the expiry rule, constant-time comparison and the parts we would change. It is part of how we built RenderScreenshot.

Short version: HMAC-SHA256 over a canonical query string (every parameter except signature, form-encoded, sorted), a required expires in Unix seconds, and a separate public/secret key pair. Because the server rebuilds the canonical string itself, parameter order and %20 versus + in the URL you send do not matter. What matters is that your signer builds the same canonical string.

Last tested: 2026-10-15. Signing code ran on Node 22.18.0, Python 3.14.8 and Ruby 4.0.7 and produced byte-identical URLs. Each URL was checked with a copy of the API's verification logic (Rack query parsing, ActiveSupport 8.1.4 Hash#to_query, OpenSSL HMAC). Timings are from an Apple M1 Pro laptop, not from production.

The problem signed URLs solve

Most API calls use a Bearer token in a header. That fails in exactly the places where screenshots are most useful:

  • An og:image meta tag. Facebook, LinkedIn, Slack and other crawlers fetch the URL with a plain GET. You cannot add a header.
  • An <img src> in an email or a dashboard. Same problem.
  • No-code tools that accept only a URL.

The easy fix, an api_key in the query string, publishes your billing key. Anyone who views the page source can then render any URL on your account. We support query-param keys for quick tests, but they do not belong in public HTML.

A signed URL fixes this. It proves "the owner of this account approved this exact request, until this time", and it reveals nothing that can sign a different request.

The flow

sequenceDiagram
  participant S as Your server
  participant P as Public page (og:image)
  participant C as Crawler or browser
  participant API as RenderScreenshot API
  S->>S: canonical query + HMAC-SHA256(rs_secret_)
  S->>P: write URL with key_id, expires, signature
  C->>API: GET /v1/screenshot?...&signature=...
  API->>API: look up rs_pub_ key, check expiry
  API->>API: rebuild canonical query, recompute HMAC
  API->>API: constant-time compare
  API-->>C: image (billed to the signing account)

The secret never crosses the network. The only things in the URL are the request parameters, the public key ID, the expiry and the signature.

Decision 1: a key pair, not the API key

We could have signed with the rs_live_ API key. We chose a separate pair:

Key Prefix Where it lives What it can do
Public key ID rs_pub_ In every signed URL Identify the account. Nothing else.
Secret key rs_secret_ Only on your server Produce signatures. Never sent.
API key rs_live_ Your backend Call any endpoint with a Bearer header.

Two reasons. First, the identifier has to be public, and a public string must not double as a credential. Second, you can revoke the signing pair without breaking the backend jobs that use your API key, and the other way round.

There is a storage consequence that is easy to miss. We store API keys as SHA-256 hashes, so a database leak does not reveal them. The authentication code looks up a key by hashing what the client sent:

# app/models/api_key.rb (excerpt)
def self.find_by_raw_key(raw_key)
  find_by(key_hash: hash_key(raw_key))
end

def self.hash_key(raw_key)
  Digest::SHA256.hexdigest(raw_key)
end

That does not work for the signing secret. To verify an HMAC, the server must compute the same HMAC, so it needs the secret itself. We store secret keys with Rails' Active Record encryption (encrypts :encrypted_key) and decrypt one only at verification time. If you build a similar system, plan for this from the start: HMAC secrets are recoverable by design, so protect them like any other encryption key.

Decision 2: a canonical string, built by the server

The hardest part of any URL-signing scheme is agreeing on the bytes to sign. A URL goes through template engines, HTML attribute escaping, crawlers that normalise percent-encoding, and proxies. If the signature covers the raw query string as sent, any of these can break it.

Our verifier does not sign what it received. It parses the query, drops signature, and serialises the rest again with Rails' Hash#to_query, which sorts the pairs and form-encodes them:

# app/controllers/concerns/api_authentication.rb (excerpt)
def generate_signature(secret_key, request)
  params_to_sign = request.query_parameters.except('signature').to_query
  OpenSSL::HMAC.hexdigest('SHA256', secret_key, params_to_sign)
end

So the canonical form is:

  1. Every query parameter except signature, including key_id and expires.
  2. Each name and value encoded like an HTML form: a space becomes +; only A-Z a-z 0-9 - _ . ~ stay as they are; everything else becomes %XX with uppercase hex.
  3. Pairs joined as name=value, sorted as plain strings, joined with &.

We tested what this buys. We signed one URL (target URL with a space, ~*'() and a nested query string) and sent variations of it to a copy of the verifier:

What we sent Result
The signed URL as generated ok
The same pairs in reverse order ok
Every + rewritten as %20 ok
The signed URL plus &format=webp invalid_signature
A URL signed over encodeURIComponent output (%20, unescaped ()) invalid_signature
A valid URL checked after its expires time expired_signature

The first three rows are the point of canonicalisation: harmless rewrites in transit do not break the signature. The last three show what still must fail: an added parameter, a signer that built different bytes, and time.

The trade-off is that every client must reproduce to_query exactly. Languages disagree on form encoding (encodeURIComponent leaves !'()* alone and writes spaces as %20, Java's URLEncoder encodes ~), so each signer below has a small encoding function. That is the cost of a canonical format, and we think it is cheaper than debugging signatures that break behind a CDN.

Decision 3: expiry is required

expires is a Unix timestamp in seconds, and a signed URL without one is rejected as missing credentials. We do not offer URLs that never expire. The documented maximum is 30 days.

Why require it:

  • A signed URL is a bearer credential for one request. Copies end up in logs, link unfurlers, archive crawlers and screenshots of your own HTML. Expiry bounds how long a stray copy can bill you.
  • Revocation is coarse. Revoking the key pair kills every URL signed with it. Expiry gives you a finer tool: short-lived URLs for dashboards and emails, longer ones for meta tags.
  • It pushes the right architecture. An image that must live for years should be stored once and served from your bucket. See storage options.

In our own signing helpers, we refuse a time-to-live over 30 days, so the limit holds where the URL is made. Two mistakes we see in practice: sending milliseconds (Date.now() without dividing by 1000), and signing at build time for a static site, so the URLs expire while the pages stay up. Sign when the page is rendered, or regenerate on each deploy within the limit.

Decision 4: constant-time comparison

Comparing the expected signature with == can return early at the first different byte. In theory an attacker can measure that timing to guess a signature byte by byte. We compare with ActiveSupport::SecurityUtils.secure_compare, which takes the same time for any input of the same length. Over the internet this attack is hard to run, but the fix costs nothing.

Signing code in three languages

Each listing is complete, reads keys from environment variables, and caps the expiry at 30 days. The three produced byte-identical output for the same inputs (same MD5 of the printed URL), and all three passed the verifier copy.

Node.js

// sign.mjs: build a signed RenderScreenshot URL (Node 22, no dependencies)
import { createHmac } from 'node:crypto';

const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot';

// Encode like Ruby's CGI.escape (what the server's Hash#to_query uses):
// keep A-Z a-z 0-9 - _ . ~, space -> "+", everything else -> %XX.
const encode = (v) =>
  encodeURIComponent(String(v))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');

export function canonicalQuery(params) {
  return Object.entries(params)
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join('&');
}

export function signUrl(params, { keyId, secret, ttlSeconds, now = Date.now() }) {
  if (ttlSeconds > 30 * 24 * 3600) throw new RangeError('expiry is capped at 30 days');
  const query = canonicalQuery({
    ...params,
    key_id: keyId,
    expires: Math.floor(now / 1000) + ttlSeconds, // seconds, not milliseconds
  });
  const signature = createHmac('sha256', secret).update(query).digest('hex');
  return `${ENDPOINT}?${query}&signature=${signature}`;
}

console.log(
  signUrl(
    { url: 'https://example.com/blog/launch', preset: 'og_card' },
    {
      keyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_...
      secret: process.env.RS_SECRET_KEY,   // rs_secret_..., server side only
      ttlSeconds: 7 * 24 * 3600,
    },
  ),
);

Python

# sign.py: build a signed RenderScreenshot URL (Python 3.10+, standard library only)
import hashlib
import hmac
import os
import time
from urllib.parse import quote_plus

ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'
MAX_TTL = 30 * 24 * 3600


def encode(value):
    # quote_plus with safe='' matches Ruby's CGI.escape: space -> '+', ~ kept.
    return quote_plus(str(value), safe='')


def canonical_query(params):
    return '&'.join(sorted(f'{encode(k)}={encode(v)}' for k, v in params.items()))


def sign_url(params, key_id, secret, ttl_seconds):
    if ttl_seconds > MAX_TTL:
        raise ValueError('expiry is capped at 30 days')
    query = canonical_query({**params, 'key_id': key_id, 'expires': int(time.time()) + ttl_seconds})
    signature = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest()
    return f'{ENDPOINT}?{query}&signature={signature}'


if __name__ == '__main__':
    print(sign_url(
        {'url': 'https://example.com/blog/launch', 'preset': 'og_card'},
        key_id=os.environ['RS_PUBLIC_KEY_ID'],
        secret=os.environ['RS_SECRET_KEY'],
        ttl_seconds=7 * 24 * 3600,
    ))

Ruby

# sign.rb: build a signed RenderScreenshot URL (Ruby 3.3+, standard library only)
require 'cgi'
require 'openssl'

ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'
MAX_TTL = 30 * 24 * 3600

def canonical_query(params)
  params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.sort.join('&')
end

def sign_url(params, key_id:, secret:, ttl_seconds:)
  raise ArgumentError, 'expiry is capped at 30 days' if ttl_seconds > MAX_TTL

  query = canonical_query(params.merge('key_id' => key_id, 'expires' => Time.now.to_i + ttl_seconds))
  signature = OpenSSL::HMAC.hexdigest('SHA256', secret, query)
  "#{ENDPOINT}?#{query}&signature=#{signature}"
end

puts sign_url(
  { 'url' => 'https://example.com/blog/launch', 'preset' => 'og_card' },
  key_id: ENV.fetch('RS_PUBLIC_KEY_ID'),
  secret: ENV.fetch('RS_SECRET_KEY'),
  ttl_seconds: 7 * 24 * 3600
)

PHP, Go and Java versions, plus the full troubleshooting table, are in the signed URLs reference. The Ruby and Go SDKs build the same string for you with generate_url and GenerateURL.

Test your signer against the verifier

Do not trust a signer until a verifier accepts it. This is the check we ran, reduced to a script you can keep in your test suite. It needs the activesupport and rack gems, the same libraries the API uses:

# verify.rb: a copy of the server-side check. Usage: ruby verify.rb urls.txt
require 'active_support/all'
require 'active_support/security_utils'
require 'rack'
require 'openssl'

def verify(url, secret, now: Time.now)
  params = Rack::Utils.parse_nested_query(URI(url).query)
  return :missing unless params['key_id'] && params['expires'] && params['signature']
  return :expired_signature if Time.at(params['expires'].to_i) < now

  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, params.except('signature').to_query)
  ActiveSupport::SecurityUtils.secure_compare(params['signature'], expected) ? :ok : :invalid_signature
end

secret = ENV.fetch('RS_SECRET_KEY')
ARGF.each_line { |line| puts verify(line.strip, secret) unless line.strip.empty? }

Run your signer in any language, pipe its output into verify.rb, and expect ok. Then append a parameter and expect invalid_signature. Use a throwaway secret for this test, never a real one.

What it costs

Signing is cheap enough to do on every page render. On an Apple M1 Pro, the Node signer above took about 3.3 microseconds per URL (median of 3 runs of 100,000 URLs). The Ruby verifier copy, including URL parsing and to_query, took about 29 microseconds per URL (3 runs of 20,000). These are laptop numbers, not production measurements, but they show that HMAC is not where a screenshot request spends its time. The render is.

Pair signed URLs with caching

A signed URL in an og:image tag gets fetched many times: once per crawler, again when a crawler refreshes its cache, and on every view if the page embeds it with <img>. Each fetch is a normal request. Two parameters change the bill:

  • cache_available=true returns a recent identical render from your account for zero credits instead of rendering again. Put it in the parameters you sign, because the signature covers it.
  • cache_ttl sets how long that render counts as fresh (1 hour to 30 days).

Or skip repeat fetches entirely: render once from your backend and publish the X-Cache-URL CDN link, which needs no signature. The trade-offs are in edge caching for screenshots and the caching reference.

What we would do differently

Honest notes from running this design:

  • The signature covers everything. That is safe, but it means you cannot add format=webp to a URL in a template later. You must sign again. A scheme that signs only a declared subset would be more flexible and much easier to get wrong. We kept the strict version.
  • One secret per account. The verifier finds the account's active signing secret from the public key. Key rotation means issuing a new pair and moving every template to it before revoking the old one. Allowing two active pairs during rotation helps here.
  • Form encoding is a portability tax. Every new SDK needs the encoding function above and a test against the verifier. If we started again, we might sign a stricter, documented byte format (RFC 3986 percent-encoding) to avoid the + versus %20 rules. The canonical rebuild on the server would stay.

Try it

Create a free account, ask for a signing key pair, and run the signer above against https://example.com with preset=og_card. The og_card preset gives you a 1200x630 image that you can put straight into a meta tag.

Related in this topic