Signing URLs and rotating keys

Copy Markdown View Source

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:

  1. Generate a new key.
  2. Add the new key to the server's list, after the current one, and restart the server: IPS_URL__KEYS=<old>,<new> for image_pipe_server, or keys: [old, new] for the Plug. The server now accepts both keys.
  3. 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.
  4. Wait until nothing requests URLs signed with the old key: after the longest expires you gave out, and after pages and caches holding those URLs have been refreshed. A URL without expires stays valid as long as the server has its key.
  5. 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