Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Uploads

The image service

The protocol a service follows to render variants.

An image service renders the variants ohne signs: it answers a variant URL with the resized, re-encoded image and caches the result. The reference implementation, @ohnejs/images, is one. This page is for writing your own, or checking one against the protocol. To use variants in an app, read image variants instead.

The URL

{images.url}/{signature}/{transforms}/{path}
https://img.example.com/2ObrtBfM78cHtN36wuvyNQXgSGGcr4cZtUQeqAhJyck/w_800,f_webp/photos/sunset.jpg
  • path is the upload's path, directory and name joined: photos/2024/sunset.jpg. Every segment is a slug, so the path needs no encoding.
  • transforms is a comma-separated list of name_value tokens, never empty.
  • signature is the base64url HMAC-SHA256 of the string {transforms}/{path}, with the secret as the key. It is 43 characters long and has no padding.

A service answers a URL only when the signature is valid, so nobody can ask it for a size ohne did not sign. Signing happens in server code, and the secret never reaches a browser.

Transforms

tokenvaluedefault
wtarget width in pixels, a positive integer
htarget height in pixels, a positive integer
fitcover fills the box and crops, contain shrinks to fit and pads, inside shrinks to fitcover
fwebp, avif, jpeg, png, or autothe source format, png for a vector source
qencoding quality, 1 to 100the service's own
pwhere a cover crop keeps its subject: center, top, topRight, right, bottomRight, bottom, bottomLeft, left, topLeftcenter
fpfocal point of a cover crop as fp_x_y, each axis 0 to 1 with up to three decimals. Replaces p
dprdevice pixel ratio the size is multiplied by, 1 to 4, up to two decimals1
ewhen the URL stops working, in epoch milliseconds, a positive integer. Written for a private file only

The token string is canonical, and ohne writes no other spelling:

  • Tokens follow the table's order.
  • A default is never written.
  • A token appears at most once.
  • e, when present, comes last.

Equal transforms therefore always give one string and one URL, so the service and every cache in front of it can identify a variant by its URL. A private file's URL changes once per expiry window, with e.

Signing

signature = base64url(HMAC-SHA256(secret, transforms + '/' + path))

This is plain HMAC over the raw string, with no derived keys, so you can implement it in three lines in any language. Test your implementation against these values:

secrettransformspathsignature
secretw_800,f_webpphotos/sunset.jpg2ObrtBfM78cHtN36wuvyNQXgSGGcr4cZtUQeqAhJyck
anotherw_320,h_320,fit_insidephotos/sunset.jpgs9YxH4SXC6gGsS97gs_WMTAcNvgSnZe7zUBB__NeUMs
secretw_800,f_webp,e_1700000000000photos/sunset.jpglznJAVjMklhx4zqJ2JblQECAwz3_EDRvDnho4upa-YA

A service holds a list of secrets and accepts a URL signed by any of them, so an app can rotate its secret without breaking pages. A public file's URL has no expiry date: it lives in pages and in caches, and a leaked URL gives access only to the one variant it names. A private file's URL carries its expiry as e inside the signed transforms, so nobody can extend it without the secret.

The fetch of a private original (step 5) signs e_<expires>/<path> the same way, under the same secret:

secretexpirespathsignature
secret1700000000000photos/sunset.jpglbshLgROalNk_URHWIraK7YDalfVmPq9WlenM8B9Ytg
another1700000000000private/scan.jpgFxcSFpGtqzYAID5ZQj1GmHfMzvs3SlcTa86NlasSgrU

What a service does

  1. Answer 405 to any method but GET and HEAD. Split the path into three parts: the first segment is the signature, the second is the transforms, and the rest is the source path. Answer 404 to fewer than three segments. Ignore the query string, for routing and for the cache key, so nobody forces a re-render by changing it.
  2. Verify the signature over the raw {transforms}/{path} string, in constant time, before parsing anything. Answer 403 when it is not valid. An unsigned service, meant for a local machine, skips this step.
  3. Parse the transforms. Answer 400 to an empty segment, an unknown token, an out-of-range value, a duplicate, or both p and fp.
  4. Refuse an expired URL: when e is present and not after the current time, answer 403.
  5. Fetch the source at {sourceURL}/{path}, where sourceURL is the service's own setting, normally the app's /uploads origin. - Sign the fetch with your secret for a URL that carries e, as ?e=<expires>&s=<signature>: expires the URL's own e, so the app holds the fetch to its link ceiling, and signature the HMAC over e_<expires>/<path>. A private file's original answers 404 to anything else. An unsigned service has no secret and fetches bare. - Fetch bare for a URL without e, and cache the two fetches apart, so a URL that never expires cannot open a private original. - Answer 404 when the origin does, and 502 when it is unreachable, both with Cache-Control: no-store. - Revalidate a fetched source with If-None-Match at a short interval, for example one minute. Key its variants by the ETag, so a replaced file gets new variants. - When the origin sends no ETag, hash the bytes and use the hash instead.
  6. Render: - Size. Multiply w and h by dpr before fitting. At dpr 1 never scale the source up: inside and contain do not enlarge, and cover shrinks the box at its own ratio until the source fills it. - Crop. Position by p or fp only for cover, since contain and inside do not crop. Pad contain to the full box with transparency, or white for jpeg. Padding is not enlargement. - Metadata. Apply the EXIF orientation, convert to sRGB, then strip the metadata. - Encode. Encode in f at q. On png, q is palette quantization; without it, png is lossless. Flatten jpeg onto white. Without f, keep the source format, and encode a vector source as png. Render the first frame of an animated source. - f_auto. Pick avif when Accept contains image/avif, else webp when it contains image/webp, else the source format. image/* alone means the source format. Add Vary: Accept to the answer. - SVG. An SVG has no pixels of its own, so the dpr 1 limit does not apply: rasterize it at the density the output needs, then fit. Never let the rasterizer load external files, URLs, or entities.
  7. Answer with the rendered bytes, Content-Type, Content-Length, and a long Cache-Control: public, max-age, or private, max-age bounded by the time left when the URL carries e. - HEAD. Send the same headers and no body. The dashboard reads a variant's size this way. - CORS. Send Access-Control-Allow-Origin: *, since the dashboard asks from another origin. - Cache. Key a render by the transforms without e, the path, the source's ETag, and the negotiated format for f_auto. Leave the signature out, so a private file's successive windows share one render. Serve a cached render only once step 5 has found its source, so a URL without e never reaches a private one.

A service may also refuse every variant the app did not name.