Token-based URL signing for secure file serving.
Generates and verifies secure tokens that prevent file enumeration attacks. Each file instance receives a unique 4-character token based on MD5 hashing.
Token Generation
Token = first 4 chars of MD5(file_uuid:instance_name + secret_key_base)
This ensures:
- Prevents file enumeration (can't guess URLs)
- Each instance has unique token
- Token changes if secret changes
- Secure comparison prevents timing attacks
- No user-guessable patterns
Examples
iex> file_uuid = "018e3c4a-9f6b-7890-abcd-ef1234567890"
iex> PhoenixKit.Modules.Storage.URLSigner.signed_url(file_uuid, "thumbnail")
"/file/018e3c4a-9f6b-7890-abcd-ef1234567890/thumbnail/a3f2"
iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "a3f2")
true
iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "xxxx")
false
Summary
Functions
Generate the 4-character token for a file instance.
The time-window token for a file instance, expiring at expires_at (unix
seconds). Without a secret_key_base there is nothing to key it with, and
the token is one no check accepts.
Whether token has the time-window shape (it may still be wrong or
expired: see verify_private_token/4).
How long one private URL window is, in seconds: the
storage_private_url_window_hours setting (default 12, at least 1).
Conditionally adds a "dzi" deep-zoom manifest URL to a urls map.
Generate a signed URL for a file instance.
The token rule the file route applies: a file in a private library takes only a time-window token (its permanent one is refused); every other file only its permanent token.
Checks a time-window token: :ok, :expired (a real token whose window
has passed), or :invalid.
Verify a token is valid for the given file and instance.
Checks a file-serving URL minted by signed_url/3 — the parsing and the
token rule the file route itself applies — and returns what it points at.
The content version a file URL carries: the first 16 hex characters of the served instance's checksum (pass the instance or the checksum).
The expiry a URL minted at now gets: now plus at least a quarter of a
window, rounded up to the end of that window.
Functions
Generate the 4-character token for a file instance.
Used internally by signed_url/2 and verify_token/4.
Arguments
file_uuid(binary) - File UUID v7instance_name(binary) - Variant name
Returns
A 4-character hex string token.
Examples
iex> PhoenixKit.Modules.Storage.URLSigner.generate_token("018e3c4a", "thumbnail")
"abc1"
The time-window token for a file instance, expiring at expires_at (unix
seconds). Without a secret_key_base there is nothing to key it with, and
the token is one no check accepts.
Whether token has the time-window shape (it may still be wrong or
expired: see verify_private_token/4).
@spec private_url_window_seconds() :: pos_integer()
How long one private URL window is, in seconds: the
storage_private_url_window_hours setting (default 12, at least 1).
Conditionally adds a "dzi" deep-zoom manifest URL to a urls map.
Returns the map unchanged unless the file is an image and the variant
set of its library makes tiles (V205; tiles: says so for a caller that
already knows, such as a grid that looked up a whole page at once with
VariantSets.tiles_among/1). The signed manifest URL
(/tiles/<token>/<file_uuid>-<version>.dzi) is what Tessera fetches to
stream tiles; the token and the version live in the path (not a query
string) so they survive Tessera's manifest → tile URL derivation.
Pass version: — the file's original instance, or its checksum — so the
tiles are cached for good and an edit moves them to new URLs. Without it
the legacy unversioned manifest (<file_uuid>.dzi) is emitted, which the
server resolves to the current version and never lets a cache keep.
This is the single source of truth for the "dzi" URL — every viewer that
builds a file urls map (media browser, detail page, lightbox) pipes
through it so the deep-zoom layer is wired consistently.
Generate a signed URL for a file instance.
Arguments
file_uuid(binary) - File UUID v7instance_name(binary) - Variant name (e.g., "thumbnail", "medium", "large")
Returns
A relative URL path with prefix: {url_prefix}/file/{file_uuid}/{instance_name}/{token}
Options
:version- the served instance (or its checksum): seeversion/1:locale- passed toRoutes.path/2(default:none):private-truefor a file in a private library (PhoenixKit.Modules.Storage.Libraries.private?/1): the URL carries a time-window token instead of the permanent one, and stops working when its window ends. The file route refuses a private file's permanent token. Mint these only after checking that the viewer may see the file (Storage.authorized_url/4does both).
Examples
iex> PhoenixKit.Modules.Storage.URLSigner.signed_url("018e3c4a-9f6b-7890", "thumbnail")
"/phoenix_kit/file/018e3c4a-9f6b-7890/thumbnail/abc1" # With default prefix
@spec verify_file_token(map(), String.t(), String.t()) :: :ok | {:error, :invalid_token | :expired_token}
The token rule the file route applies: a file in a private library takes only a time-window token (its permanent one is refused); every other file only its permanent token.
@spec verify_private_token(String.t(), String.t(), String.t(), integer()) :: :ok | :expired | :invalid
Checks a time-window token: :ok, :expired (a real token whose window
has passed), or :invalid.
Verify a token is valid for the given file and instance.
Arguments
file_uuid(binary) - File UUID v7instance_name(binary) - Variant nametoken(binary) - 4-character token from URL
Returns
Boolean indicating if token is valid.
Examples
iex> file_uuid = "018e3c4a-9f6b-7890"
iex> token = PhoenixKit.Modules.Storage.URLSigner.generate_token(file_uuid, "thumbnail")
iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", token)
true
iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "xxxx")
false
@spec verify_url(String.t(), keyword()) :: {:ok, %{uuid: String.t(), variant: String.t(), version: String.t() | nil}} | {:error, :malformed | :not_found | :invalid_token | :expired_token}
Checks a file-serving URL minted by signed_url/3 — the parsing and the
token rule the file route itself applies — and returns what it points at.
Accepts a full URL or a path, with or without the kit's URL prefix and a
locale segment: ".../file/<uuid>/<variant>/<token>?v=<version>".
Returns {:ok, %{uuid: uuid, variant: variant, version: version | nil}},
or {:error, reason} with :malformed (not a file URL), :not_found
(no such file, a system-managed one, or a trashed one), :invalid_token
or :expired_token (a private file's time-window token that has run out).
{:ok, _} means well-formed and signed by this app, not authorized. A
public file's token is short and permanent — it stops URL guessing, it
does not grant access. Check the viewer's rights to the file separately
before acting on it. Needs the database: whether a file is private is a
property of its library.
Only the path is checked — /file/<uuid>/<variant>/<token> — never the
scheme or host, so https://anywhere.example/file/… verifies too. Act on
the returned uuid (rebuild the URL from it); never fetch or render the
string you were handed.
Options
:allow_trashed— also accept a trashed file (defaultfalse).
The content version a file URL carries: the first 16 hex characters of the served instance's checksum (pass the instance or the checksum).
A URL with a version is content-addressed: FileController serves it with
a year-long immutable lifetime only while it names the bytes the variant
holds now, and redirects to the current version otherwise — so a cached
copy can never be different bytes (an edited or redacted image). Build URLs
with signed_url(uuid, variant, version: instance) wherever the instance
is at hand; an unversioned URL still works and is revalidated.
@spec window_end(integer(), pos_integer()) :: integer()
The expiry a URL minted at now gets: now plus at least a quarter of a
window, rounded up to the end of that window.