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.jpgpathis the upload's path,directoryandnamejoined:photos/2024/sunset.jpg. Every segment is a slug, so the path needs no encoding.transformsis a comma-separated list ofname_valuetokens, never empty.signatureis 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
| token | value | default |
|---|---|---|
w | target width in pixels, a positive integer | |
h | target height in pixels, a positive integer | |
fit | cover fills the box and crops, contain shrinks to fit and pads, inside shrinks to fit | cover |
f | webp, avif, jpeg, png, or auto | the source format, png for a vector source |
q | encoding quality, 1 to 100 | the service's own |
p | where a cover crop keeps its subject: center, top, topRight, right, bottomRight, bottom, bottomLeft, left, topLeft | center |
fp | focal point of a cover crop as fp_x_y, each axis 0 to 1 with up to three decimals. Replaces p | |
dpr | device pixel ratio the size is multiplied by, 1 to 4, up to two decimals | 1 |
e | when 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:
| secret | transforms | path | signature |
|---|---|---|---|
secret | w_800,f_webp | photos/sunset.jpg | 2ObrtBfM78cHtN36wuvyNQXgSGGcr4cZtUQeqAhJyck |
another | w_320,h_320,fit_inside | photos/sunset.jpg | s9YxH4SXC6gGsS97gs_WMTAcNvgSnZe7zUBB__NeUMs |
secret | w_800,f_webp,e_1700000000000 | photos/sunset.jpg | lznJAVjMklhx4zqJ2JblQECAwz3_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:
| secret | expires | path | signature |
|---|---|---|---|
secret | 1700000000000 | photos/sunset.jpg | lbshLgROalNk_URHWIraK7YDalfVmPq9WlenM8B9Ytg |
another | 1700000000000 | private/scan.jpg | FxcSFpGtqzYAID5ZQj1GmHfMzvs3SlcTa86NlasSgrU |
What a service does
- Answer
405to any method butGETandHEAD. Split the path into three parts: the first segment is the signature, the second is the transforms, and the rest is the source path. Answer404to fewer than three segments. Ignore the query string, for routing and for the cache key, so nobody forces a re-render by changing it. - Verify the signature over the raw
{transforms}/{path}string, in constant time, before parsing anything. Answer403when it is not valid. An unsigned service, meant for a local machine, skips this step. - Parse the transforms. Answer
400to an empty segment, an unknown token, an out-of-range value, a duplicate, or bothpandfp. - Refuse an expired URL: when
eis present and not after the current time, answer403. - Fetch the source at
{sourceURL}/{path}, wheresourceURLis the service's own setting, normally the app's/uploadsorigin. - Sign the fetch with your secret for a URL that carriese, as?e=<expires>&s=<signature>:expiresthe URL's owne, so the app holds the fetch to its link ceiling, andsignaturethe HMAC overe_<expires>/<path>. A private file's original answers404to anything else. An unsigned service has no secret and fetches bare. - Fetch bare for a URL withoute, and cache the two fetches apart, so a URL that never expires cannot open a private original. - Answer404when the origin does, and502when it is unreachable, both withCache-Control: no-store. - Revalidate a fetched source withIf-None-Matchat a short interval, for example one minute. Key its variants by theETag, so a replaced file gets new variants. - When the origin sends noETag, hash the bytes and use the hash instead. - Render: - Size. Multiply
wandhbydprbefore fitting. Atdpr1 never scale the source up:insideandcontaindo not enlarge, andcovershrinks the box at its own ratio until the source fills it. - Crop. Position byporfponly forcover, sincecontainandinsidedo not crop. Padcontainto the full box with transparency, or white forjpeg. Padding is not enlargement. - Metadata. Apply the EXIF orientation, convert to sRGB, then strip the metadata. - Encode. Encode infatq. Onpng,qis palette quantization; without it,pngis lossless. Flattenjpegonto white. Withoutf, keep the source format, and encode a vector source aspng. Render the first frame of an animated source. -f_auto. PickavifwhenAcceptcontainsimage/avif, elsewebpwhen it containsimage/webp, else the source format.image/*alone means the source format. AddVary: Acceptto the answer. - SVG. An SVG has no pixels of its own, so thedpr1 limit does not apply: rasterize it at the density the output needs, then fit. Never let the rasterizer load external files, URLs, or entities. - Answer with the rendered bytes,
Content-Type,Content-Length, and a longCache-Control: public, max-age, orprivate, max-agebounded by the time left when the URL carriese. -HEAD. Send the same headers and no body. The dashboard reads a variant's size this way. - CORS. SendAccess-Control-Allow-Origin: *, since the dashboard asks from another origin. - Cache. Key a render by the transforms withoute, the path, the source'sETag, and the negotiated format forf_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 withoutenever reaches a private one.
A service may also refuse every variant the app did not name.