How we protected media: per-request authorization
The Telegram model for securing attachments: no token, no access. Here's how and why we moved away from signed URLs.
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
- The client requests
/uploads/messages/2026/04/abc.jpgwith anAuthorization: Bearer <token>header. - Nginx doesn’t serve the file immediately — it first issues an internal sub-request to
/internal/auth-attachment. - A Rust service extracts the path from
X-Original-URI, pulls theuser_idfrom the token, and checks Postgres: does this user have access to the message that owns this file? - 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
nginxwithauth_request+proxy_cacheaxumendpoint in Rustsqlxcompile-time queries against Postgres- Partial index on
attachment_pathandattachment_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.