← All articles

Serving Paid Downloads Safely with Short-Lived Signed URLs (S3/R2)

By Shuvo Haldar4 min read
S3Object StorageSecuritySaaSNode.js
Serving Paid Downloads Safely with Short-Lived Signed URLs (S3/R2)

A creator uploads a paid ebook. A customer buys it. Now the file has to reach that one customer — and only that customer — without ever becoming a URL that gets pasted into a Telegram group and passed around for free. On Krey, the platform I built, this applied to every downloadable product, every course video, and every completion certificate. Getting it wrong means creators watch their paid work leak.

The wrong instinct is to put the file behind a "public" bucket and a hard-to-guess path. Obscurity isn't access control — once one person has the link, everyone does, forever. What you want is a link that works for a few minutes and then dies.

Animated timeline of a signed URL: its five-minute lifetime bar drains from valid to expired, then the link flips to a 403 dead-link state stamped LINK DEAD.
The signature is valid for five minutes, so a leaked link is a dead 403 almost immediately.

The layout: private bucket, signed reads

Nothing paid is ever public. Uploads land in object storage (S3 or Cloudflare R2 — same API surface) under a private prefix, and the database stores only the key, never a URL. When an authorized buyer asks to download, the server checks that they actually own the thing, then mints a short-lived signed URL on the spot.

// Only reached after we've confirmed this user owns this product.
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";

export async function signDownload(objectKey: string, filename: string) {
  const cmd = new GetObjectCommand({
    Bucket: PRIVATE_BUCKET,
    Key: objectKey,
    ResponseContentDisposition: `attachment; filename="${filename}"`,
  });
  // Re-signed on every authorized read; expires fast on purpose.
  return getSignedUrl(s3, cmd, { expiresIn: 300 }); // 5 minutes
}

The expiresIn: 300 is the whole idea. The signature is only valid for five minutes, so even if the URL leaks, it's a dead link almost immediately. And because I re-sign on every authorized read instead of storing a URL, there's no long-lived link sitting in the database waiting to be exfiltrated.

Authorize first, sign second — never the reverse

The ordering matters more than the crypto. The signing function above is dumb on purpose: it doesn't know who's asking. The ownership check lives in the route before it, so signing is the reward for passing the check, not part of it.

Flow diagram: a download request hits an ownership check against the database; a no branch returns 403 with no URL minted, and a yes branch calls signDownload with a 300-second expiry and returns a fresh signed URL to the buyer.
Ownership is checked before anything is signed — a denied request never produces a URL at all.
router.get("/downloads/:productId", async (c) => {
  const user = c.get("user");
  const owns = await hasPurchased(user.id, c.req.param("productId")); // DB truth
  if (!owns) throw new ForbiddenError("You don't own this product");

  const product = await getProduct(c.req.param("productId"));
  const url = await signDownload(product.fileKey, product.filename);
  return c.json({ url });
});

If you sign first and check ownership in the browser, you've already lost — the signed URL exists before you decided the person was allowed to have it. The server is the only thing that gets to say yes.

Course video and certificates: same trick, different content

Course lessons stream from the same private storage through the same signing path, so a paid video can't be embedded on some other site by copying its src. Auto- generated completion certificates are handled identically — a certificate is proof someone finished a paid course, so it's not something you want sitting at a guessable public URL either. One delivery mechanism covers files, video, and PDFs.

One cleanup detail that bites people: when a creator replaces an asset (a new cover, a re-uploaded lesson), delete the old object. Otherwise your private bucket slowly fills with orphaned keys nobody references, and you're paying to store leaks waiting to happen. Replacement should mean replace, not accumulate.

Why I keep expiries short and resist "convenient" long ones

There's always pressure to bump the expiry — "make it an hour so the download doesn't fail on slow connections." I push back. A signed URL only needs to live long enough to start the transfer; once the download begins, the connection is already open and the expiry no longer matters for that request. Five minutes is plenty. A one-hour link is twelve times the window for someone to grab and reshare it. Short expiries cost you almost nothing and buy you real protection.

Takeaways

  • Store paid content in a private bucket; keep only the key in your DB, never a URL.
  • Re-sign on every authorized read with a short expiry (minutes) — no long-lived links anywhere.
  • Check ownership before signing. Signing is what happens after the server says yes, never before.
  • Use the same signed-delivery path for files, video, and certificates, and delete replaced objects so orphans don't pile up.

Signed URLs aren't clever, and that's the point. They turn "who can download this paid file" into a question the server answers fresh every single time.


I build commerce and SaaS platforms end-to-end. If you're working on something similar, let's talk.

Building something similar?

I'm a senior full-stack & DevOps engineer available for full-time roles and projects worldwide.