Signed URLs for Private Files
Short-lived links to private files that work without a session — for native app clients and embedded HTML (inline email images) that cannot send cookies or custom headers.
The contract: minting is the authorization
File::mintSignedUrl() is the authorization statement. Only code that has
already verified the viewer may access the file calls it — e.g. a mailbox
thread fetch that has verified viewer scope mints links for the thread's
attachments as it builds the response. There is no hook registry and no
plugin callback in the serving path; authorization stays in the feature code
that knows the rules.
Minting
$url = $file->mintSignedUrl($size_key, $ttl_seconds, $format);
// e.g. /uploads/thumb/photo.jpg?expires=1751500000&sig=ab12...$size_key—'original'(default) or an ImageSizeRegistry variant key.$ttl_seconds— keep short; default 300 (5 minutes). Consumers that re-fetch their parent resource for fresh links use the default; a consumer that mints once per page open and sits idle (the web mail readers' inline images) uses a longer TTL — 3600 — and accepts that a link outliving it renders broken until the page is reopened.$format—'short'(relative, default) or'full'(absolute).
/uploads pattern — it routes through
serve.php's gate, never a bucket URL.Validation and serving
The /uploads/* route in serve.php checks expires/sig query
parameters before the ownership gate:
- Valid and unexpired (constant-time compare) → the file is served with no session consulted.
- Invalid, expired, or absent → falls through to the normal
is_viewable()gate. A signed miss is never its own error state: an expired link in a logged-in owner's browser still works via their session; a stranger gets the same 404 they would get with no link at all.
Cache-Control: private, no-store — a cached
copy must not outlive the grant. Private cloud-stored files keep their
never-302 rule: a valid signature streams the bytes through PHP from the
private bucket, exactly like the sessioned path.Ranged downloads
Every served response carries Accept-Ranges: bytes, and a Range request
gets the span it asked for. This is what lets a client resume an interrupted
download from where it stopped instead of starting over — the difference
between a 4 GB transfer that survives a dropped connection and one that does
not.
- A valid single range →
206withContent-Range: bytes start-end/totaland exactly those bytes. Supported forms:bytes=start-end,bytes=start-(to the end),bytes=-suffix(the last N bytes). A range whose end runs past the object is clamped rather than refused. - A syntactically valid range that starts past the end, or any range on a
zero-length object →
416withContent-Range: bytes */total. - A multi-range request, an unknown range unit, or a malformed header → the
whole object,
200. RFC 7233 permits ignoring aRangethe server will not honor, and serving everything is always a correct answer.
{file_id}:{size_key}:{expires}, and
asking for part of a file is not asking for a different file.Cloud-offloaded blobs pass the range to the storage driver
(CloudStorageDriver::get_range() → S3 GetObject with a Range header),
so a resume moves the requested bytes and no more. This applies to the
original variant, whose size is known from the blob without a round trip;
image variants are small enough that ranging them is pointless and they are
served whole.
Files served through a registered decrypt hook (server-custody sealed
sources — not Drive) are the exception: their plaintext is produced in memory
from the entire ciphertext, so there is nothing to seek into. They advertise
no Accept-Ranges and ignore a Range header rather than half-honoring it.
The signing key
A dedicated 32-byte key, stored SecretBox-encrypted in stg_settings under
file_signed_url_key. It is deliberately separate from secret_box_key (key
separation): deleting the row rotates the key, which invalidates every
outstanding signed URL and nothing else — a non-event given short TTLs.
update_database provisions the key on every install and upgrade
(File::provisionSigningKey()), so a deployment has one before anything needs
to mint. That timing is the point. The setting is declared in settings.json,
so the row is seeded empty and first-mint fills it; but minting writes a long
encrypted blob to stg_settings, which cannot seal to a user, so
SealedEgressGuard refuses the write in any request that has
already opened sealed content. Opening a protected mail thread with an
attachment is exactly that: it decrypts the bodies, then mints signed URLs for
the attachments. Provisioning at deploy time, cold, keeps first-mint out of
that request.
A request may still mint the key itself if none exists — filling the seeded row when it is empty, and never overwriting a key already in use.
Composition: Drive share links
A Drive public share link (/s/{token}, see Drive) is the *durable,
revocable grant; the signed URL stays the short-lived transport. The share
page authorizes the visitor against the link (live, not revoked, password
satisfied), then mints a fresh signed URL per download. Revoking or expiring the
link stops new signed URLs from being issued; any already-minted URL simply lapses
at its short TTL. The two layers are independent — the link controls whether a
visitor may fetch, the signed URL controls this one fetch*.
Tests
tests/functional/files/signed_urls_test.php (see Testing) — covers no-session
serving, expiry, tamper, size-key binding, and the ownership-gate fallback.
tests/functional/files/signing_key_provision_test.php — covers first-mint:
filling the seeded-empty row, leaving a key already in use alone, and minting
end to end.