Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Protected File Downloads in Your Next.js SaaS -- Serve Reports and Course Materials Only to Paying Customers

August 22, 2026
nextjscloudinarysaasauthboilerplate

You built a feature. Users pay for it. And then you realize nothing is stopping a free user from grabbing the download link from a paying user and using it forever.

This is the file access problem. It shows up when you add course materials, PDF reports, invoices, or any deliverable that should only reach paying customers. The upload is easy. Protecting the download is where most tutorials stop.

Here is how to close that gap using the auth and Cloudinary setup that is already in your Next.js SaaS boilerplate.

Why direct Cloudinary URLs are not enough

When you upload a file to Cloudinary, it gets a public URL by default. Anyone who knows the URL can access it -- no account required. That works fine for a public avatar or a product image. It does not work for a PDF that someone paid $49 to access.

There are two ways to fix this:

  1. Signed URLs -- Cloudinary generates a time-limited URL that expires after a few minutes. The file is still served by Cloudinary, but the link becomes useless quickly.
  2. Proxy through your server -- Your API fetches the file from Cloudinary and streams it to the user. You control access entirely.

Signed URLs are simpler and faster. The proxy approach gives you more control -- useful if you want to log every download or enforce a download count limit. Start with signed URLs unless you have a specific reason to proxy.

The pattern: protect at the route level

The key insight is that the download URL should never be the Cloudinary URL directly. Instead, your app exposes a route like /api/files/[fileId] that:

  1. Reads the user's JWT from the Authorization header
  2. Checks that the user has access (paid plan, purchased content, or whatever your model is)
  3. Generates a short-lived signed URL from Cloudinary
  4. Redirects the user to it

This means your React component never touches a Cloudinary URL. It just calls your route. If the user is not authenticated or not on the right plan, they get a 401 or 403 -- not a working file URL.

What you need in the boilerplate

The boilerplate already has:

  • JWT auth via getUserFromRequest() in lib/auth.ts -- one call to validate the token and get the user
  • Cloudinary configured in lib/cloudinary.ts -- you store the public_id in your database, not the full URL
  • The HttpError class for consistent error responses
  • A service layer where plan checks live without polluting the route

The route stays thin: validate the token, call a service that checks access and returns the signed URL, redirect.

// app/api/files/[fileId]/route.ts
export async function GET(req: Request, { params }: { params: { fileId: string } }) {
  const user = await getUserFromRequest(req); // throws 401 if missing or invalid
  const signedUrl = await fileService.getSignedUrl(user.id, params.fileId); // throws 403 if no access
  return Response.redirect(signedUrl);
}

The service does the real work: look up the file in your database, confirm the user has access, then call Cloudinary's SDK to generate a signed URL with a short expiry -- 60 seconds is usually enough for a redirect.

The access model decision

Before you build, answer one question: how does a user earn access to a file?

The three most common models:

  • Subscription tier -- the user is on the Pro plan or higher. Check their plan in the database or query Stripe.
  • One-time purchase -- the user bought a specific product. You need a purchases table linking userId to productId.
  • Organization membership -- any member of a paid org can access shared files. Use your orgMember records.

Each model is a different query in your service layer. The route does not care which one you use -- it just calls fileService.getSignedUrl() and trusts the service to throw if access should be denied. That separation keeps the route testable and the logic in one place.

Keep the public_id, not the URL

A common mistake is storing the full Cloudinary URL in your database instead of the public_id. The URL is public and permanent. The public_id is neutral -- you construct signed or unsigned URLs from it at render time, depending on who is asking.

If you are already uploading via /api/upload in the boilerplate, the Cloudinary response includes the public_id. Store that column. When you need to display a public image like a profile photo, construct the URL on the client. When you need to protect a download, route it through your API.

See how image uploads work in Next.js with Cloudinary for the upload half of this pattern.

When to use this

Add protected downloads when:

  • You sell course materials, templates, or digital downloads
  • You generate per-user PDF reports such as invoices or analytics exports
  • You offer a document library as a paid feature
  • You want an audit trail of who downloaded what and when

If every file on your platform is public -- a portfolio site, a public blog -- you do not need this at all.

Build it this weekend

Protected downloads look complicated but they are mostly plumbing. The auth is already there. The Cloudinary client is already there. The service pattern is already there.

The work is: one new database column for public_id, one service method for the access check, one thin route that ties them together.

Get the Next.js SaaS boilerplate and ship your paid content feature this weekend -- without rebuilding auth or file handling from scratch.