Skip to content

Security

Galley runs untrusted HTML on our machines and hands back files behind public links. This page is what we do about that.

  • A key is glr_sk_ plus 24 random bytes in base64url.
  • Only the SHA-256 of the key is stored, along with its first 13 characters for display. The plaintext exists on the single response that issued it and nowhere else — we cannot read a key back to you, and neither can anyone who reaches our database.
  • Lookups are by hash, and revoked keys are excluded at the query, so revocation takes effect on the next request.
  • Key values are never logged. Request logs carry the method, path, status, duration and an x-request-id — quote that id when you report something.
  • The REST API sends no CORS headers, so a browser cannot call it from page JavaScript. There is no configuration that makes a key safe in a client-side bundle. Browser clients use the MCP endpoint, which does send CORS headers and supports the keyless trial.

Details and rotation: Authentication.

A template is one self-contained HTML document with Liquid expressions. During a render:

Shell, processes, environment variablesno access of any kind
Filesystem reads or writesnone — there is no filesystem API exposed to the page
Liquid partials and includesdisabled: no root search path, no relative references, so {% include %} and {% render %} cannot reach anything
Unknown Liquid filtershard error at render time, rather than silently empty output
Cookies, localStorage, cached state from another rendernone — every render gets a fresh incognito browser context
Service workersblocked
JavaScriptruns, because chart and layout templates need it — but only inside that isolated page
Networkonly through the guarded fetcher below
file://, ftp:// and every other schemerefused before a connection is attempted

Template source is parsed and its JSON Schema compiled when the version is created, so a malformed template fails at publish time with an invalid_request rather than at render time.

Versions are immutable and content-addressed. The checksum of a version covers its engine, source, schema and options, and it is part of every cache key — so a stored render is always attributable to exact template bytes, and changing a template can never silently change what a cached render contains.

Everything that leaves the service on a customer’s behalf — template images, stylesheets, fonts, scripts, and webhook deliveries — goes through a single outbound primitive that enforces:

  • http and https only, ports 80 and 443 only
  • a hostname denylist applied before DNS, covering cloud metadata hosts, localhost, *.internal, *.local, *.cluster.local and friends
  • every resolved address checked against private, loopback, link-local, CGNAT, multicast, benchmarking, documentation and reserved ranges, for IPv4 and IPv6 including IPv4-mapped, NAT64 and 6to4 forms
  • connection to the exact address that was validated, defeating DNS rebinding between check and connect
  • redirects followed manually, at most 3 hops, each hop revalidated from scratch
  • a 5-second wall-clock budget and a 10 MB response cap

The full rule set, the error it produces and its effect on a render are on Images, fonts and other assets.

What a blocked fetch looks like
curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{"template":"og-card","format":"png","data":{"title":"x","image_url":"http://169.254.169.254/latest/meta-data/"}}'
400 Bad Request
{
"error": {
"type": "asset_blocked",
"message": "Refused to fetch http://169.254.169.254/latest/meta-data/: address 169.254.169.254 is in a blocked range (169.254.0.0/16 link-local / cloud instance metadata).",
"docs_url": "https://galleyrender.com/docs/errors/asset_blocked",
"details": {
"url": "http://169.254.169.254/latest/meta-data/",
"reason": "address 169.254.169.254 is in a blocked range (169.254.0.0/16 link-local / cloud instance metadata)"
}
}
}
  • Templates, renders, usage and keys are all scoped by account id at the query level. Another account’s render id is a 404, never a 403 with a hint.
  • The render cache is keyed by account as well as by content: two accounts that render byte-identical documents do not share a stored object and cannot learn anything about each other.
  • Storage keys are namespaced by account public id: renders/<account>/<yyyy>/<mm>/<cache key>.<ext>.
  • Every render gets its own browser context, closed when the render ends.

Render output is not served from an authenticated endpoint. It sits in Cloudflare R2 and is handed out as a presigned URL that expires after an hour (SIGNED_URL_TTL_SECONDS, default 3600).

What that means in practice:

  • A signed URL is a bearer credential for one file. Anyone holding it can fetch that file until it expires. Treat a link you paste into an email or a ticket as public for the next hour.
  • It carries no API key, so it is safe to give to an end user without exposing your account.
  • It cannot be extended. Getting a new one is GET /v1/renders/:id, which re-signs on every call and requires your key.
  • Object storage is private; there is no public bucket listing, and the object path contains a SHA-256 nobody can guess.

On a self-hosted or local deployment using the filesystem driver, the URL points at /v1/files/<key>?X-Amz-Expires=3600&X-Amz-Signature=… instead, verified with an HMAC and a constant-time comparison. Same properties: no key needed, dead after the deadline.

Stored render fileskept for the plan’s retention window, then eligible for deletion. 30 days by default (RENDER_RETENTION_DAYS); 90 days on Growth and Scale.
expires_at on a renderthe retention deadline for that object. Not the URL expiry.
Render metadatathe row — id, template ref, status, sizes, cost — is kept for accounting after the file is gone.
Request payloadsdata and options are stored with the render, because they are what makes it reproducible and cacheable. Do not put secrets in them.
API keysSHA-256 only.
Verification tokensSHA-256 only, single use, expiring.
Trial identitywhen no client id is supplied, the trial is keyed by a salted hash of IP and User-Agent. The raw address is not stored against the account.

Do not render documents that impersonate a real company or person — fake invoices under someone else’s letterhead, forged receipts, counterfeit certificates. Accounts doing this are suspended, and a suspended account gets permission_error on every render.

Email support@galleyrender.com. If it is a security issue:

  • Include the x-request-id from the response header, or the render id, and a timestamp in UTC.
  • Do not include an API key in the report. If a key is involved, say so and revoke it first.
  • Give us a way to reproduce it. A minimal template and payload beats a description.

We will confirm receipt, and we will not pursue action against good-faith research that does not degrade the service or touch other customers’ data.