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.

4 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

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.

Presigned URLs vs. proxied downloads, least-privilege access tokens, CDN-fronted object storage, short-lived credentials.