Require signed URLs, so the server serves only the URLs your application
generated, and replace the signing key later without breaking URLs in use.
This assumes ImagePipe running in your app or as image_pipe_server, with
URLs built by ImagePipe.URL. Signing and source concealment
explains what a signature covers.
Generate a key
A signing key is a hex string of at least 64 digits (32 bytes). A shorter key
stops image_pipe_server at startup and raises ArgumentError when ImagePipe
is configured in your app. Generate 32 random bytes:
openssl rand -hex 32
Store the result as a secret, for example in an environment variable named
IMAGE_PIPE_SIGNING_KEY. Keep the key on your servers.
Configure the server
image_pipe_server
Pass the key in IPS_URL__KEYS, or in a file named by IPS_URL__KEYS_FILE
for Docker and Kubernetes secrets:
IPS_URL__KEYS=0123abcd…
The server reads it at startup. See
[url].
Plug
Give the instance the key. base_url, the path the router forwards to
ImagePipe.Plug, lets the builder in the next step produce full URLs:
# lib/my_app/application.ex
{ImagePipe,
name: MyApp.Images,
base_url: "/images",
keys: [System.fetch_env!("IMAGE_PIPE_SIGNING_KEY")],
sources: [...]}From now on, the server answers unsigned URLs with 403 and the body
invalid signature.
Sign URLs in the builder
In an app that serves its own URLs, build them from the instance's URL configuration, which already has the key:
url_config = ImagePipe.url_config(MyApp.Images)An application that builds URLs for a separate server uses the same key:
url_config =
ImagePipe.URL.config(
base_url: "https://img.example.com/images",
keys: [System.fetch_env!("IMAGE_PIPE_SIGNING_KEY")]
)Every URL generated from url_config is signed. With the second
configuration:
thumbnail = ImagePipe.URL.new(url_config) |> ImagePipe.URL.group(resize: [width: 400])
ImagePipe.URL.url!(thumbnail, "photos/beach.jpg")
# "https://img.example.com/images/sig=Sr0wHrHp…/w=400/src/photos%2Fbeach.jpg"Generate URLs in server code, such as a controller or template, and send the
browser only the finished URL. Assign thumbnail to the template, then:
<img src={ImagePipe.URL.url!(@thumbnail, @photo.source)} />To sign a path you built yourself, pass it to ImagePipe.URL.sign_path/2
and prepend the base URL yourself:
"https://img.example.com/images" <> ImagePipe.URL.sign_path("/w=400/src/photos/beach.jpg", url_config).
Add an expiry
To make a URL stop working at a given time, pass expires as a Unix time in
seconds:
ImagePipe.URL.new(url_config, expires: System.os_time(:second) + 3600)After that second, the URL answers 410 with the body expired. A new
expiry gives a new URL, which browsers and CDNs can't serve from their
cache. To keep one URL per image for a while, round the expiry up, for
example to the end of the day.
Check that signing works
Request a generated URL, then the same URL with one option changed:
curl -s -o /dev/null -w "%{http_code}\n" "https://img.example.com/images/sig=Sr0wHrHp…/w=400/src/photos%2Fbeach.jpg"
# 200
curl -s -w "\n%{http_code}\n" "https://img.example.com/images/sig=Sr0wHrHp…/w=800/src/photos%2Fbeach.jpg"
# invalid signature
# 403
Rotate the key
During the switch, both lists hold the old and the new key:
- Generate a new key.
- Add the new key to the server's list, after the current one, and restart
the server:
IPS_URL__KEYS=<old>,<new>forimage_pipe_server, orkeys: [old, new]for the Plug. The server now accepts both keys. - Put the new key first in the builder's list and keep the old key second:
keys: [new, old]. New URLs are signed with the new key. - Wait until nothing requests URLs signed with the old key: after the
longest
expiresyou gave out, and after pages and caches holding those URLs have been refreshed. A URL withoutexpiresstays valid as long as the server has its key. - Remove the old key from both lists.
In an app that serves its own URLs, the server and the builder share one
list, so steps 2 and 3 are one change to keys: [new, old]. When several
nodes run the app, deploy keys: [old, new] to every node first, then
keys: [new, old], so no node signs with a key another node doesn't accept
yet.
To see which key verified each request, turn on request logging. Set
log_requests = true in the server's
[telemetry],
or call ImagePipe.Telemetry.attach_default_logger/1 for the Plug. Each
signed request logs the position of its key in the server's list, counting
from 0:
image_pipe parse: ok (signing key 1)During step 4, the server's list is [old, new], so signing key 0 means a
URL signed with the old key was requested. With one shared [new, old]
list, the old key is signing key 1. In your app, a custom handler reads the
same number as :sig_key_index from the [:image_pipe, :parse] stop event
(see telemetry events).
Next steps
- Shared URL settings: everything else the server and an external builder must agree on.
- Source concealment: hide the image's path in the URL as well.
- Serving images through a CDN: cache lifetimes for URLs with an expiry.