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.
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:
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 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:
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.
The boilerplate already has:
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.
Before you build, answer one question: how does a user earn access to a file?
The three most common models:
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.
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.
Add protected downloads when:
If every file on your platform is public -- a portfolio site, a public blog -- you do not need this at all.
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.