Dear Next.js Afficionado,

The other day I faced one interesting question…

It’s not something I had never thought about, but it’s interesting enough to write about in this week’s newsletter, so here is what it was:

I had a zip archive containing my new free template Darky that I wanted to give to subscribers and customers only.

How could I achieve that?

Any file in the public directory can be downloaded freely by anyone who knows its URL, right?

So I needed a way to give access to signed-in users only…

Well, it turned out there were several possible ways to achieve this in my use case, and the three most feasible were:

  • Put the file in a private folder and serve it with a custom endpoint
  • Hide the public bucket URL behind a custom endpoint
  • Generate a “signed URL” to a resource in a cloud storage

Let’s see exactly how we can do it with next.js one at a time.

How To Protect A File With A Custom Endpoint

So this is the base case where you don’t use any cloud storage solution and just need a simple way to restrict access to the file.

The first step is to create a folder OUTSIDE the public one that will contain all the files you want to protect. You need to do this because if your important files are somewhere in the public folder, then anybody who knows their URL will be able to download them.

Even worse, their URLs can circulate around the web. People can share them on Twitter or Facebook. They can post them on their own websites or spread them out through email.

So say good bay to all the benefits you hoped for…

That’s why we put the protected files in a private folder, so they don’t have a public URL and can’t be accessed through an HTTP request.

The second step is to create a custom endpoint.

It could be something like this…

import {NextApiRequest, NextApiResponse} from "next";
import path from 'path';
import {createReadStream, existsSync} from 'fs';
import withSession from '@/lib/auth/session';
import WithSession from '@/lib/types/api/WithSession';


export const handler = async (req: WithSession<NextApiRequest>, res: NextApiResponse) => {

    if (req.method !== 'GET') {
        res.status(400).json({message: 'Not existing endpoint'})
        return
    }

    //Check if the user is signed in
    if (!req.session?.credentials?.userId) {
        res.status(403).json({message: 'Access denied!'})
        return
    }

    try {
        const {filename} = req.query;

        //If no file name, return 404
        if (!filename || !process.env.PROTECTED_FILES_FOLDER) {
            res.status(404).json({message: 'Not found'});
            return;
        }

        const filePath = path.join(process.env.PROTECTED_FILES_FOLDER, filename as string);
        
        if (!existsSync(filePath)) {
            res.status(404).json({message: 'Not found'});
            return;
        }

        //Set the proper headers
        res.setHeader('Content-Type', 'application/zip');
        res.setHeader('Content-Disposition', `attachment; filename=${filename}`);

        //Create a read stream and pipe to the response
        createReadStream(filePath).pipe(res);

    } catch (exception) {
        //Conceal the exception, but log it
        console.warn(exception)
        res.status(500).json({message: 'Internal Server Error'});
    }
}

export default withSession(handler);

Here it accepts the filename as a parameter. If the requested file doesn’t exist, it returns a response with the HTTP response code 404 – Not Found.

Before that, it checks if the user is signed in or has a valid JWT token, etc. It depends on the specific method you use to authenticate users.

Once it validates that the user is signed in, it creates a read stream and pipes it as a response.

In case the user is not signed in, it returns an HTTP response with code 401 – Unauthorized.

How To Protect A Public Bucket URL With A Custom Endpoint

I’m using Google Cloud, so I keep my files in their Cloud Storage, where I can have private and public “buckets.”

Because the templates I wanted to give away are not so of a critical asset, I decided to go the easier way and protect them just enough so they are not freely accessible. At the same time, I didn’t spend a lot of time implementing complex solutions.

So I made a public bucket for all my templates, and I created the following API endpoint:

import {NextApiRequest, NextApiResponse} from "next";
import fetch from 'node-fetch';
import stream from 'stream';
import {promisify} from 'util';
import withSession from '@/lib/auth/session';
import WithSession from '@/lib/types/api/WithSession';


export const handler = async (req: WithSession<NextApiRequest>, res: NextApiResponse) => {

    if (req.method !== 'GET') {
        res.status(400).json({message: 'Not existing endpoint'})
        return
    }

    //Check if the user is signed in
    if (!req.session?.credentials?.userId) {
        res.status(403).json({message: 'Access denied!'})
        return
    }

    try {
        const pipeline = promisify(stream.pipeline);
        const {filename} = req.query;
        const url = `https://storage.googleapis.com/some-bucket/${filename}`;

        //Request the file
        const fileRes = await fetch(url);

        //If no file, return 404
        if (!fileRes.ok) {
            res.status(404).json({message: 'Not found'});
            return;
        }

        //Set the proper headers
        res.setHeader('Content-Type', 'application/zip');
        res.setHeader('Content-Disposition', `attachment; filename=${filename}`);

        //Pipe the file data
        await pipeline(fileRes.body, res);

    } catch (exception) {
        //Conceal the exception, but log it
        console.warn(exception)
        res.status(500).json({message: 'Internal Server Error'});
    }
}

export default withSession(handler);

As you can see, it’s not very different from the first code snippet I showed you.

Here I make all the mentioned checks, but instead of reading the file that resides in the local file system, I make a GET request to Google Cloud Storage and then pipe the response to the client’s browser.

Simple, right?

But bear in mind that it’s not very secure.

My bucket is public, and if somebody somehow discovers its URL, they can download all of my templates at once without providing their email or signing in with their credentials.

How to Generate Signed URLs

This last way to protect a file is the most secure one.

Most cloud providers offer a mechanism to share unique URLs to files issued on a user basis. So basically, the user requests a link, you handle that request with some kind of js library, and then you return the result.

The result is a “signed URL” that allows the user to download the file and is valid for a certain amount of time. So after one hour or twenty-four hours (you choose the period), this signed URL is no longer good and displays an error when opened.

I dropped some links in the resource section if you need more info.

Which Of The 3 Ways Should You Use?

Well, that’s a tough question. It really depends on your specific use case and your desire to take risks.

If you offer something valuable for download, you want to put in place the highest protection you can come up with. There are bad people out there who make money by getting access to expensive stuff and later reselling them for peanuts. So you need protection.

If you offer to download something not so valuable and you’re ready to accept the risk, then maybe it’s not worth implementing a complex access control mechanism because it takes time and costs more money.

But no matter what, always find a way to protect your assets!

Be Happy,

Sashe Vuchkov,
Full-stack developer at BuhalBu.com

Summary:

  • Files offered for download to subscribers or customers should not be put in the public folder of Next.js because they will be freely accessible.
  • There are three feasible ways to protect a file, and which one you choose depends on the level of acceptable risk and how valuable the file is.
  • The core idea is to use an API endpoint that checks the user’s credentials and serves the protected file if everything is OK.

Resources