Signed URLs are a classic. Create an HMAC signature with an expiration, hand it to the client, and let nginx verify it with the secure_link module. It works. But it has an unpleasant property: the moment a link leaks, the file is open to anyone for the remainder of its lifetime. Longer TTL is friendlier to caching, scarier in the wrong hands. Shorter TTL — the other way round.

We decided to switch to the Telegram model: per-request authorization.

How it works

  1. The client requests /uploads/messages/2026/04/abc.jpg with an Authorization: Bearer <token> header.
  2. Nginx doesn’t serve the file immediately — it first issues an internal sub-request to /internal/auth-attachment.
  3. A Rust service extracts the path from X-Original-URI, pulls the user_id from the token, and checks Postgres: does this user have access to the message that owns this file?
  4. If yes — returns 200, and nginx serves the file. If no — 403, and the client sees an error.

Why this is better

  • Zero tail: when a member leaves a group, they lose access to every file in it immediately — even if they’ve saved every link.
  • No secret on the client: the client just sends the JWT it already has. No HMAC keys to leak.
  • Auditability: every media access flows through our service. We can log, rate-limit, or block at will.

About caching

The main objection to this model is extra load. Every media request triggers another DB lookup. We fixed that with caching: nginx caches the authorization result for 30 seconds keyed on (Authorization, URI). Thirty seconds is a compromise — barely visible under load during chat scrolling, barely opens any window when someone is removed from a group.

Under the hood

  • nginx with auth_request + proxy_cache
  • axum endpoint in Rust
  • sqlx compile-time queries against Postgres
  • Partial index on attachment_path and attachment_thumb_path

We migrated atomically: the Kotlin service, nginx config and Rust binary all shipped in a single command. The client already knew how to send Authorization ahead of time, so downtime was literally seconds.

That’s what it looks like when security and speed aren’t at odds.