Skip to content
VectorFast Render docs

Signed URLs

How VectorFast Render signed image URLs work, the exact canonicalization and HMAC-SHA-256 rules, Node.js and Python examples, and how to keep the signing secret safe.

Short answer: a signed URL is GET /i/{template}.{format} with the template parameters in the query string and a sig that is an HMAC-SHA-256 of the path plus the sorted query. The server verifies sig before it reads any cache, renders on the first request, and serves the same bytes with a one-year immutable cache header after that.

Current status

Every url returned by POST /v1/renders, the MCP render tool, and GET /v1/templates is already a signed URL. Per-key signing secrets for building URLs on your own server are not issued yet in early access; the algorithm below is what the server verifies today and is what you will use once they are.

Anatomy

https://vectorfast.ai/i/launch-post.png?badge=New&brand=default&h=630&title=Hello&w=1200&sig=Besvpf_bmr9a…
Query keyMeaning
template paramsEvery template parameter, as a string. Arrays (such as changelog.items) are JSON-encoded.
brandBrand kit: default, or an account kit pinned to a version such as bk_…@3. An account kit without @n is rejected so the URL can never change pixels.
w, hSize; must be one of the template's sizes. Defaults to the template's default size.
scale2 for double-density output; omit for 1.
vReserved for a template major-version pin; it is signed but does not select a version yet.
kPublic key id of the key that signed the URL.
sigThe signature. Always last.

brand, k, v, w, h, scale, and sig are reserved; no template uses them as parameter names.

How the signature is computed

  1. Take every query pair except sig.
  2. Decode, then re-encode each key and value with RFC 3986 rules: encodeURIComponent, plus !, ', (, ), and * percent-encoded.
  3. Sort the pairs by encoded key, then by encoded value, comparing byte by byte.
  4. Join them as key=value with & to get the canonical query.
  5. The payload is /i/{template}.{format}? followed by the canonical query. Signing the path means a URL signed for one template or format cannot be replayed against another.
  6. sig = base64url(HMAC-SHA-256(secret, payload)) without padding.

Because values are decoded before re-encoding, a=1%20b and a=1+b-style differences in escaping do not change the signature.

Node.js

import { createHmac } from 'node:crypto'

// Server-side only. Never ship the signing secret to a browser, a mobile app or a repo.
const SIGNING_SECRET = process.env.VECTORFAST_SIGNING_SECRET

function encode(value) {
  return encodeURIComponent(value).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
}

export function signRenderUrl({ template, format = 'png', params, brand, width, height, keyId }, secret = SIGNING_SECRET) {
  const query = {}
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null)
      continue
    query[key] = typeof value === 'string' ? value : Array.isArray(value) ? JSON.stringify(value) : String(value)
  }
  if (brand) query.brand = brand
  if (keyId) query.k = keyId
  if (width) query.w = String(width)
  if (height) query.h = String(height)

  const canonical = Object.entries(query)
    .map(([key, value]) => [encode(key), encode(value)])
    .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
    .map(([key, value]) => key + '=' + value)
    .join('&')
  const path = '/i/' + template + '.' + format
  const sig = createHmac('sha256', secret).update(path + '?' + canonical).digest('base64url')
  return 'https://vectorfast.ai' + path + '?' + (canonical ? canonical + '&' : '') + 'sig=' + sig
}

Python

import base64
import hashlib
import hmac
import json
import os
from urllib.parse import quote

# Server-side only. Never ship the signing secret to a browser, a mobile app or a repo.
SIGNING_SECRET = os.environ.get("VECTORFAST_SIGNING_SECRET", "")


def _encode(value: str) -> str:
    return quote(value, safe="-_.~")


def sign_render_url(template, params, fmt="png", brand=None, width=None, height=None, key_id=None, secret=SIGNING_SECRET):
    query = {}
    for key, value in params.items():
        if value is None:
            continue
        query[key] = json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, list) else str(value)
    if brand:
        query["brand"] = brand
    if key_id:
        query["k"] = key_id
    if width:
        query["w"] = str(width)
    if height:
        query["h"] = str(height)

    pairs = sorted((_encode(k), _encode(v)) for k, v in query.items())
    canonical = "&".join(f"{k}={v}" for k, v in pairs)
    path = f"/i/{template}.{fmt}"
    digest = hmac.new(secret.encode(), f"{path}?{canonical}".encode(), hashlib.sha256).digest()
    sig = base64.urlsafe_b64encode(digest).decode().rstrip("=")
    return f"https://vectorfast.ai{path}?{canonical + '&' if canonical else ''}sig={sig}"

Python's quote with safe="-_.~" encodes exactly the RFC 3986 reserved set, and sorting the encoded tuples sorts by key, then value, as the server does.

Responses

SituationStatusError code
Valid signature, first request200, X-Cache: miss—
Valid signature, repeat200, X-Cache: hit-edge or hit-r2—
Missing, malformed, or wrong sig; any changed parameter403invalid_signature
Unknown template404template_not_found
Unknown or deleted brand kit404brand_kit_not_found
Bad format, size, or params; a parameter given twice400invalid_params

Successful responses carry Cache-Control: public, max-age=31536000, immutable, X-Cache-Key, and X-Cache. The signature is checked before any cache is read, so a tampered URL never reaches cached bytes.

Keep the secret safe

  • Sign on your server or at build time; send only finished URLs to browsers.
  • Anyone with a signed URL can load that one image; they cannot change a single parameter without invalidating sig.
  • Rotating the signing secret invalidates every URL signed with it. Images already rendered stay in the R2 cache under their cache key and are served again to newly signed URLs with the same inputs.

Updated 2026-10-30 · Markdown version