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.
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:
// 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:
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.
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.
Related content
Clonify.io SaaS Platform
Template marketplace SaaS platform migrated from WordPress to Next.js with custom CMS and payments.
Clonify.io: Turning a Slow WordPress Marketplace into a High-Performance Next.js SaaS Platform
Rebuilding a digital-products marketplace from WordPress to Next.js and Payload CMS, with a custom JSON caching layer and secure, subscription-aware asset delivery.
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.