Security
Galley runs untrusted HTML on our machines and hands back files behind public links. This page is what we do about that.
API keys
Section titled “API keys”- 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.
What a template can and cannot do
Section titled “What a template can and cannot do”A template is one self-contained HTML document with Liquid expressions. During a render:
| Shell, processes, environment variables | no access of any kind |
| Filesystem reads or writes | none — there is no filesystem API exposed to the page |
| Liquid partials and includes | disabled: no root search path, no relative references, so {% include %} and {% render %} cannot reach anything |
| Unknown Liquid filters | hard error at render time, rather than silently empty output |
| Cookies, localStorage, cached state from another render | none — every render gets a fresh incognito browser context |
| Service workers | blocked |
| JavaScript | runs, because chart and layout templates need it — but only inside that isolated page |
| Network | only through the guarded fetcher below |
file://, ftp:// and every other scheme | refused 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.
SSRF-safe fetching
Section titled “SSRF-safe fetching”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:
httpandhttpsonly, ports 80 and 443 only- a hostname denylist applied before DNS, covering cloud metadata hosts,
localhost,*.internal,*.local,*.cluster.localand 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.
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/"}}'{ "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)" } }}Isolation between accounts
Section titled “Isolation between accounts”- Templates, renders, usage and keys are all scoped by account id at the query level. Another
account’s render id is a
404, never a403with 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.
The signed-URL model
Section titled “The signed-URL model”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.
Data retention
Section titled “Data retention”| Stored render files | kept 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 render | the retention deadline for that object. Not the URL expiry. |
| Render metadata | the row — id, template ref, status, sizes, cost — is kept for accounting after the file is gone. |
| Request payloads | data and options are stored with the render, because they are what makes it reproducible and cacheable. Do not put secrets in them. |
| API keys | SHA-256 only. |
| Verification tokens | SHA-256 only, single use, expiring. |
| Trial identity | when 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. |
Content rules
Section titled “Content rules”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.
Reporting something
Section titled “Reporting something”Email support@galleyrender.com. If it is a security issue:
- Include the
x-request-idfrom 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.
See also
Section titled “See also”- Authentication
- Images, fonts and other assets
- Caching and signed URLs
- Webhooks — every delivery is signed with
X-Galley-Signature; verify it before you act