Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
The Problem #
Clonify.io sells premium digital products — Figma files, UI kits, templates. Every product's download button pointed straight at the file's real storage URL. The URL wasn't linked anywhere on the public site, but it wasn't a secret either: predictable filenames meant anyone who guessed or scraped one link could reconstruct others, purchased or not.
Context #
Clonify's assets sit in object storage (S3-compatible), addressed by a key like products/clonify-ui-kit/source.fig. The original download button just rendered https://cdn.clonify.io/products/clonify-ui-kit/source.fig directly into an <a href>. Nothing about that URL checks whether the visitor bought the product — it's a static file path, and object storage serves whatever it's asked for.
What I Tried #
First instinct was to keep serving files from the API instead of the CDN — stream the file through an authenticated Express route:
router.get("/download/:productId", requireAuth, async (req, res) => {
const owns = await hasPurchased(req.user.id, req.params.productId);
if (!owns) return res.status(403).end();
const file = await storage.getObject(keyFor(req.params.productId));
file.pipe(res); // proxy the bytes through our own server
});What Went Wrong #
This checks ownership correctly, but every download now round-trips through the API process — for a multi-hundred-MB Figma file, that's the Node event loop holding a long-lived connection and shuttling bytes it has no reason to touch. Object storage is built to serve large files directly and efficiently; proxying defeats that, and it doesn't scale past a handful of concurrent downloads without dedicating real resources just to plumbing.
The Solution #
Split the two concerns instead of merging them: the API's job is deciding whether someone can download a file, not the download itself. Once permission is confirmed, generate a presigned URL — a normal storage URL with a signature and expiry baked into its query string — and hand that to the client. The client downloads directly from storage; storage checks the signature, not a session.
router.get("/download/:productId", requireAuth, async (req, res) => {
const owns = await hasPurchased(req.user.id, req.params.productId);
if (!owns) return res.status(403).json({ error: "not purchased" });
const url = await storage.getSignedUrl("getObject", {
Bucket: BUCKET,
Key: keyFor(req.params.productId),
Expires: 60, // seconds — long enough to start the download, not to share the link
});
res.json({ url });
});The frontend calls this route, gets a URL back, and redirects the browser to it — the real object key never appears in any response the client can inspect ahead of the signed, expiring one.
Why It Works #
A presigned URL is the storage provider's own access-control mechanism, not something layered on top of it: the signature is computed from the object key, the expiry timestamp, and a secret the API holds, and the storage service verifies that signature itself before serving bytes. That means large-file delivery still happens at the storage layer's speed, not the API's, while access is still gated by a real check — the signature simply can't be produced without going through the ownership check first, and it stops working once Expires passes. Sharing a signed link only leaks a 60-second window instead of a permanent path.
Lessons Learned #
"Authenticated route" and "the endpoint that serves the bytes" don't have to be the same request. Splitting authorize from deliver let each half do what it's actually good at — the API enforces business rules, the storage service moves data — instead of forcing one process to do both badly.
What I Would Do Differently #
I'd default new file-delivery features to presigned URLs from day one instead of reaching for the API-proxy version first. The proxy approach looks simpler because it's one route instead of a route-plus-redirect, but it hides a scaling problem that only shows up once file sizes or concurrent downloads grow.
Related Concepts #
Presigned URLs vs. proxied downloads, least-privilege access tokens, CDN-fronted object storage, short-lived credentials.
Related content
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
A 'Hidden' S3 URL Isn't Access Control — Short-Lived Signed URLs Are
TIL that a private-looking direct download URL for a purchased file is still a public URL forever, and the fix is generating a signed, expiring link on demand after checking ownership server-side.
Designing a Complete Delivery-Management System as a Solo Developer
TIL that a system spanning a browser extension, a React Native app, an API, and a database only stayed maintainable solo because I treated the API as the one real product and everything else as a thin client of it.