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.

2 min read

Sabin Shrestha

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

Today I learned that an unguessable-looking object URL is not the same thing as a protected download, on Clonify's premium template files. The old flow stored a direct URL on the product record and handed it out once checkout finished:

download route (before)
// product.fileUrl → https://cdn.clonify.io/assets/template-42.zip
return NextResponse.redirect(product.fileUrl);

Nothing about that URL expires or checks who's asking. Anyone who ever saw it — a shared cart screenshot, a browser history entry, a referrer header on some third-party page — could redownload the file indefinitely, whether they still owned it or not.

The fix moves the file into a private bucket and only ever hands out a signed URL, generated on demand after an ownership check:

app/api/downloads/[productId]/route.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: "us-east-1" });
 
export async function GET(req: Request, { params }: { params: { productId: string } }) {
  const user = await getSessionUser(req);
  const owns = await verifyPurchase(user.id, params.productId);
  if (!owns) return new Response("Forbidden", { status: 403 });
 
  const command = new GetObjectCommand({
    Bucket: "clonify-assets",
    Key: `products/${params.productId}.zip`,
  });
  const url = await getSignedUrl(s3, command, { expiresIn: 60 });
 
  return Response.json({ url });
}

This works because the bucket has no public read access at all — an anonymous GetObject request simply fails. The only way to read the file is a request signed with AWS credentials the browser never sees, and getSignedUrl bakes that signature plus an expiry into the URL itself. The ownership check now runs on every download attempt instead of once at checkout, so a refund or a subscription lapse actually revokes access instead of leaving an old link working forever.

Tip

Sixty seconds feels aggressive, but the link only has to survive the moment between the click and the browser starting the download. Generating a fresh one is a single authenticated request — there's no upside to a longer window, only a longer one for a leaked link to matter.