· 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 at most 30 days ahead, a separate public/secret key pair, and signed URLs that work only for GET /v1/screenshot. 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; code excerpts and the verifier script re-checked against the API source on 2026-10-18. 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 (max 30 days)
  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. An rs_pub_ or rs_secret_ value sent as a Bearer token is rejected with 401; only rs_live_ keys call the API directly. Second, you can revoke the signing pair without breaking the backend jobs that use your API key, and the other way round. An account can hold several pairs. Each public key ID verifies only against the secret created with it, and revoking either half revokes both.

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/services/signed_url_verifier.rb (excerpt)
# @query is request.query_parameters: the parsed query string, string keys.
def signature_for(secret)
  OpenSSL::HMAC.hexdigest('SHA256', secret, @query.to_h.except('signature').to_query)
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 verifier checks the value before it computes any HMAC:

# app/services/signed_url_verifier.rb (excerpt)
MAX_LIFETIME = 30.days
CLOCK_SKEW = 5.minutes

def check_expires
  raw = @query['expires'].to_s
  unless raw.match?(/\A\d{1,12}\z/)
    return { code: 'invalid_expiration', message: 'expires must be a Unix timestamp in seconds' }
  end

  expires_at = Time.at(raw.to_i)
  return { code: 'expired_signature', message: 'Signed URL has expired' } if expires_at < Time.current

  if expires_at > Time.current + MAX_LIFETIME + CLOCK_SKEW
    return { code: 'invalid_expiration', message: 'Signed URL expires must be at most 30 days in the future' }
  end

  nil
end

The five minutes of skew allow for a signer whose clock runs a little ahead of ours.

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.

Our signing helpers below also refuse a time-to-live over 30 days, so the mistake shows up where the URL is made, not when a crawler gets a 401. Two mistakes we see in practice: sending milliseconds (Date.now() without dividing by 1000), which the API rejects as more than 30 days ahead, 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: signed URLs only render with GET

A signed URL authenticates only GET (or HEAD) on /v1/screenshot, with no request body. On any other endpoint or method, the key_id, expires and signature parameters are ignored and the request gets the normal authentication error.

The reason is what the signature covers: the query string, and nothing else. A POST request carries its options in a JSON body that the signature never saw. If we accepted a signed query on POST, anyone holding a leaked og:image URL could replay its signature with a different body: another target URL, a full-page capture, a webhook. Restricting signed URLs to GET means every option that affects the render is inside the signed bytes. It is also why only GET parameters can go into a signed URL.

Decision 5: 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. Every official SDK builds the same string for you: generateUrl in Node.js, PHP and Java, generate_url in Python and Ruby, and GenerateURL in Go.

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_credentials unless params['key_id'] && params['expires'] && params['signature']
  return :invalid_expiration unless params['expires'].match?(/\A\d{1,12}\z/)
  return :expired_signature if Time.at(params['expires'].to_i) < now
  return :invalid_expiration if Time.at(params['expires'].to_i) > now + (30 * 24 * 3600) + 300

  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.
  • Rotation took a second version. At first the verifier accepted any active secret of the account for a public key, which made pairs hard to reason about. Now each secret is linked to the public key it was created with, an account can hold several pairs, and revoking one half revokes both. Rotation is: create a new pair, move your templates to it, then revoke the old pair.
  • 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, create a signing key pair under API Keys in the dashboard, 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