Gated File Downloads in Next.js: The Order the Checks Must Run In
Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.
A gated download is not a file link with an if in front of it. It is an ordered chain — authenticate, verify, validate, authorize, rate-limit and record, then sign — and the order is the security property. Get the sequence wrong and a route that looks locked will tell an anonymous caller which products exist.
This post is about the delivery half of selling a file. The purchase half — checkout, the signed webhook, and how an order becomes an entitlement — is the LemonSqueezy post. Everything here assumes the entitlement already exists and asks the harder question: how does the byte stream get to the buyer, once, provably, and to nobody else.
Start with the storage bucket, not the route
If the object is publicly readable, nothing downstream matters. A private bucket plus short-lived signed URLs is the only shape that works, because it makes the absence of a valid link the default state.
releases/ ← private bucket, no public read
asoc-nest-shop/
nextjs/
asoc-nest-shop-nextjs-v1.4.0.zip
The route never streams the file itself. It decides yes or no, asks storage for a URL that expires, and redirects. Streaming through your own function would put every megabyte of every download through a serverless invocation with a timeout — expensive, slow, and prone to failing at 90%.
/** Signed URLs are meant to be used immediately, not stored or shared. */
export const SIGNED_URL_TTL_SECONDS = 60;
Sixty seconds is deliberate. The URL exists to survive a redirect, not to be pasted into a group chat. A one-hour TTL is a shareable link with extra steps.
The order the checks must run in
Here is the full chain, in the order our own route runs it, with the reason each step is where it is:
| # | Check | Failure | Why here |
|---|---|---|---|
| 1 | Session exists | 401 | Everything below leaks information to anonymous callers |
| 2 | Email verified | 403 | Same gate as redemption; unverified accounts are unproven |
| 3 | Params well-formed | 400 | Cheap, and stops malformed input reaching storage paths |
| 4 | Entitlement covers target | 403 | The single authorization decision, from server-held state |
| 5 | Rate limit + audit write | 429 | Atomic, and it must be recorded before a URL exists |
| 6 | Sign the object URL | 404 if absent | Only reachable by an entitled, in-budget caller |
| 7 | Record delivery | — | Fail-open metadata, after the decision |
Steps 3 and 4 are the pair people invert. Validating params after the authorization check feels tidier — authorize first, then care about details — but it means a garbage slug reaches your entitlement lookup and your storage path builder. Validate the shape, then decide the right.
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function isValidSlug(value: string): boolean {
return value.length > 0 && value.length <= 64 && SLUG_RE.test(value);
}
The authorization decision belongs in a pure function
The single most useful structural choice we made was to put the yes/no in a function that touches nothing:
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
// Every premium template, all framework editions — but never the
// backend zip (that's all_access only).
return target.framework !== "backend";
case "template_single":
// One template, all its framework editions (framework is not a lever;
// owning the product covers every edition).
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
export function authorizeDownload(slots: Slot[], target: DownloadTarget) {
return slots.some((s) => slotCovers(s, target));
}
No database, no request object, no framework. Every branch is a unit test, and the exhaustive switch on a union type means adding a fourth entitlement kind is a compile error at this exact spot rather than a silent false in production.
The rule that makes it safe: slots comes from the server, keyed by the session's user id, never from the request. A client that can name its own entitlements does not have entitlements.
Rate limiting: a flat cap cannot serve both customers
The obvious limit — say 30 downloads an hour — is generous for someone who bought one template and outright broken for someone who bought everything. An all-access buyer's legitimate first-day behaviour is sweeping the whole catalog, which for us is already 116 editions. They would hit the wall a quarter of the way through something they paid for.
So derive the budget from what the caller actually owns:
export const RATE_LIMIT_MIN_PER_HOUR = 30;
export const RATE_LIMIT_PULLS_PER_ENTITLED_EDITION = 3;
export function hourlyDownloadLimit(slots: Slot[]): number {
let entitledEditions = 0;
for (const product of catalog) {
for (const edition of product.editions) {
if (edition.status !== "ready") continue;
if (authorizeDownload(slots, {
productSlug: product.slug,
framework: edition.framework,
})) {
entitledEditions++;
}
}
}
return Math.max(
RATE_LIMIT_MIN_PER_HOUR,
entitledEditions * RATE_LIMIT_PULLS_PER_ENTITLED_EDITION,
);
}
Three properties worth stealing. It scales with the catalog by construction, so no constant needs raising when products ship. It cannot be inflated by anyone who has not bought the breadth, because the count comes from the same server-held slots as the authorization decision — and it costs no extra I/O, since those slots are already in hand. And the floor keeps a single-template buyer's experience unchanged.
The race condition in every count-then-insert limiter
This is the bug that survives code review. The natural implementation is:
const used = await countDownloadsThisHour(userId); // ← read
if (used >= limit) return tooManyRequests();
await recordDownload(event); // ← write
Two concurrent requests both read used = limit - 1, both pass, both write. The limit is advisory at any real concurrency, and "concurrency" here includes a buyer double-clicking a button.
The fix is to make the check and the write one serialized operation in the database, holding a per-user lock:
create or replace function public.record_download_within_limit(
p_user_id uuid, p_product_slug text, p_framework text,
p_version text, p_ip inet, p_user_agent text, p_limit int
) returns int
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
recent int;
begin
-- Serialize concurrent downloads for THIS user so count+insert is atomic.
-- Transaction-scoped: released on commit. Keyed on the user-id hash, so
-- different users never contend with each other.
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
select count(*)::int into recent
from public.download_events
where user_id = p_user_id
and created_at > now() - interval '1 hour';
if recent >= p_limit then
return -1; -- over limit — record nothing
end if;
insert into public.download_events
(user_id, product_slug, framework, version, ip, user_agent)
values
(p_user_id, p_product_slug, p_framework, p_version, p_ip, p_user_agent);
return recent + 1;
end;
$$;
revoke execute on function public.record_download_within_limit(
uuid, text, text, text, inet, text, int
) from anon, authenticated, public;
One round trip, one lock, one truth. Over-limit returns -1 and writes nothing; under the limit it records the event and returns the new count.
Three things in there are load-bearing and easy to omit. security definer with a pinned search_path — a definer function without one is a privilege-escalation vector, because a caller who can create a schema can shadow the tables you meant. The revoke execute from anon and authenticated, so only server-side code holding the service role can call it at all. And the lock before the read, not between the read and the insert.
That last one has a subtlety worth writing in a comment, because it is invisible in review: correctness here relies on READ COMMITTED isolation, the Postgres default. The lock is taken before any table read, so the select takes a fresh snapshot after the previous holder committed and the second caller sees the first caller's insert. Under REPEATABLE READ or SERIALIZABLE the snapshot can freeze earlier and the second caller reads a stale count — the race returns, quietly, because someone raised an isolation level for an unrelated reason.
The application side then has one job — interpret that integer strictly:
export function interpretRecordDownloadResult(data: unknown) {
if (typeof data !== "number") {
throw new Error("record_download_within_limit returned a non-numeric result");
}
return { withinLimit: data >= 0 };
}
A null or a string means schema drift, and the caller turns that throw into a fail-closed 500. No signed URL is ever issued when the limit-and-audit write could not run. That is the difference between an audit log and an audit log with holes in it exactly where the incident was.
Fail closed here, fail open there
Not every write deserves to block a download, and being deliberate about which is what keeps the route both safe and usable.
- Rate limit and audit — fail closed. If it throws, return 500. Issuing the file without recording it destroys the only record of who has what.
- Delivery metadata — fail open. We separately record successful 302s for refund eligibility. If that write fails, swallow it and still return the redirect. The accepted cost is stated in the code: that order may stay refund-eligible slightly longer than it should. A buyer who paid should never be blocked by a bookkeeping row.
Write the accepted gap in a comment next to the catch. An unexplained empty catch reads as a bug forever.
Two details that make it feel finished
The saved filename is not the object path. Storage paths should be slug-based and stable; buyers should get something they recognise in their downloads folder. Content-Disposition decouples them:
export function downloadFilename(
productSlug: string, framework: string, version: string, previewUrl: string | null,
): string {
if (previewUrl) {
try {
const host = new URL(previewUrl).hostname;
const suffix = ".vercel.app";
if (host.endsWith(suffix) && host.length > suffix.length) {
const name = host.slice(0, -suffix.length);
if (name) return `${name}-v${version}.zip`;
}
} catch {
// fall through to the slug-based fallback
}
}
return `${productSlug}-${framework}-v${version}.zip`;
}
A missing object is a 404, not a 500. If a release has not been uploaded yet, the caller is entitled and the server is healthy — the file just is not there. Returning 500 sends you hunting an outage that does not exist.
The client-side gate is a convenience, never a control
Our product cards show a download menu to owners, resolved once per page load from a memoized lookup. It exists so buyers do not click through to a 403. It is not a permission check, and the route re-runs authorizeDownload on every single request regardless of what the UI decided.
Say this out loud in a comment where the UI gate lives, because the next person to touch it will be tempted to trust it.
Testing it without a live stack
Keep the route handler thin and put the chain behind an interface. Ours takes a DownloadDeps object — getSession, listActiveSlots, recordDownloadWithinLimit, createSignedUrl, recordDelivery — so every branch is exercisable with in-memory fakes, no session, no database, no storage.
The tests worth writing are the ones for the failures: anonymous gets 401 and learns nothing else; unverified gets 403 before any lookup; a valid-shaped slug for a product you do not own gets 403 and never touches storage; the over-limit path returns 429 having written nothing; a throwing audit write returns 500 with no URL issued.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Public bucket "temporarily" | The file is on the open web forever | Private bucket + short-lived signed URLs |
| Long signed-URL TTL | Links get shared and keep working | 60 seconds; it only has to survive a redirect |
| Entitlements read from request params | Anyone can claim anything | Server-held slots, keyed by session user id |
| Count-then-insert rate limit | Limit is advisory under concurrency | One atomic RPC under a per-user lock |
| Flat hourly cap | Breaks your best customer's first day | Derive the budget from the entitlement surface |
| Audit write fails open | Downloads with no record, exactly when it matters | Fail closed on the audit path |
| Refund bookkeeping fails closed | A paid buyer is blocked by a metadata row | Fail open, and document the gap |
| Missing object returns 500 | You debug an outage that is a missing upload | Map "not found" to 404 |
| Streaming the file through the route | Timeouts, cost, failures near the end | Redirect to the signed URL |
| Trusting the UI's owner check | A crafted request bypasses it | Re-authorize per request, always |
Frequently asked questions
Why redirect instead of proxying the bytes? Proxying puts the whole transfer inside a function invocation with a timeout and a bandwidth bill, and a dropped connection restarts from zero. A 302 to a signed URL hands the transfer to storage, which is built for it. Proxy only if you must inspect or transform the payload per request — and then reconsider whether you must.
Is 60 seconds too short if the user is on a slow connection? No, because the TTL bounds when the download may start, not how long it may take. A transfer that begins inside the window completes normally. The failure mode you are protecting against is a link pasted somewhere public an hour later.
How do I stop a buyer redistributing the file after they download it? You cannot, technically, and DRM on a zip of source code is theatre. What you can do is make redistribution attributable: an audit row per download with user, product, version, timestamp and IP means a leaked file traces back to an account. Volume limits per account are the enforcement mechanism; the audit trail is what makes someone answerable.
Should entitlements be version-scoped? Ours are deliberately not. The route resolves the current version at request time, so an existing buyer's next download is the newest release automatically and nothing is re-issued. Version-scoping entitlements means every release becomes a fulfilment job, which is a lot of machinery to solve a problem most catalogs do not have.
Where should the email-verified check go? Immediately after authentication and before anything else. It is a property of the session, not of the resource, so it belongs with the other session checks — and putting it after the entitlement lookup means an unverified account can probe which products it owns.
Templates that ship the storefront around it
The download route is the last mile; the storefront is what gets someone to it. Three of ours are built for catalogs where the checkout matters more than the browsing.
ASoc Groove is a vinyl record shop covering new releases, limited pressings, hi-fi gear and record-care accessories. ASoc Tune is a premium headphones storefront across five categories with collection tabs. ASoc Watt is a smart-electronics store spanning six categories with a build-your-own-bundle flow.
Browse the full set of Next.js shop templates or the Tailwind shop templates. For the layer underneath — which database and auth model to build this on — see Supabase vs Firebase for a template-based SaaS.
