Skip to content

Password Protection for Cloudflare Pages#

Cover image for Password Protection for Cloudflare Pages

Cloudflare Pages is a fantastic service for hosting static sites: it is extremely easy to set-up, it deploys your sites automatically on every commit to your GitHub or GitLab repos, and its free plan is incredibly generous; with unlimited users, sites, requests, and bandwidth.

For the purposes of deploying and previewing static sites, Pages is very similar to products like Vercel or Netlify. However, one of the features it lacks in comparison to its main competitors is the ability to protect environments using a simple password-only authorization.

You have the option to limit access to your Pages environment by integrating with Cloudflare's Access product (which is free for up to 50 users), and you should definitely look into it if you're looking for a full-blown authentication mechanism.

But if what you need is a basic layer of protection so that your sites are not immediately available to the public, a simple password-only authentication feature like the one offered by Netlify and Vercel might be exactly what you need.

In this post I'm going to talk about how you can password-protect your Cloudflare Pages site by building a small authentication server powered by Cloudflare Workers; Cloudflare's serverless platform.

You can see a demo of the final result here: https://cloudflare-pages-auth.pages.dev/ (password: password).

Image description


TLDR#

If you want to add password-protection to your own Cloudflare Pages site, just head to the repo and follow the instructions there.

You basically need to do two things:

  1. Copy the contents of the functions directory from the repo into your own project.
  2. Add a CFP_PASSWORD environment variable to your Cloudflare Pages dashboard with the password you want to use.

And that's it! The next time you deploy, your site will be password-protected 🎉

If you're interested in learning more about how this works, just read along!


Pages, Workers, and Functions#

Cloudflare Pages is primarily a service for hosting static sites, which means that to run our small authentication application, we'll need a backend environment that can execute our server-side functions.

That's where Cloudflare Workers come in, which is a serverless execution environment (similar to AWS Lambda or Vercel Edge Functions) that we can use to run our authentication application on Cloudflare's amazingly fast edge network.

Pages and Workers are two separate products, and while they integrate really well together, if you want to build an application that uses them both, you'd typically need to create two separate projects and manage and deploy them individually. Thankfully, we can use a feature called Cloudflare Functions to make things a lot easier.

Functions are a feature of Cloudflare Pages that serve as a link between our Pages site and a Workers environment. The advantage of using Functions is that we can manage and deploy them as part of our Pages project rather than having to create a separate Workers application.

To create a function, we simply need to create a functions folder in the root of our project, and add JavaScript or TypeScript files in there to handle the function's logic. This will also generate a routing table based on the file structure of this folder. So if we create the following script as functions/api/hello-world.js:

// functions/api/hello-world.js

export async function onRequest(context) {
  return new Response("Hello, world!");
}

Enter fullscreen mode Exit fullscreen mode

When we deploy our site, this function will be available under the URL: https://your-site.pages.dev/api/hello-world.

If you want to learn more about Functions and Workers, check out the various resources on the Cloudflare Docs site.


Middleware#

Our small authentication application needs a way to intercept all requests to our Pages project so that we can verify that the user has access to the site, or redirect them to the login page if they don't. We can do this using Middleware, which are a special type of function that sits between the user's request and the route handler.

To create a middleware for all of the pages on our site, we need to add a _middleware.js file to the functions folder. Here's an example middleware that gives you a different response if you're trying to access the /admin route.

export async function onRequest(context) {
  const { request, next } = context;
  const { pathname } = new URL(request.url);

  if (pathname === '/admin') {
    return new Response('You need to log in!')
  }

  return await next();
}

Enter fullscreen mode Exit fullscreen mode


A Simple Password-Protection Server#

Now that we've seen how Functions, Workers, and Middleware work, we can start designing our application so that it works on any Pages site. We'll keep the application fairly simple:

  • We'll use a middleware to intercept all request to the site and redirect them to a login page if they're not authenticated.
  • We'll create a route that handles submissions to the login form, and verifies that the user has provided the right password (which is stored in an environment variable).
  • If they provide the right password, we'll set a cookie with a hash that subsequent requests will use to verify that they're authenticated.

Here's what the overall design looks like:

Image description

You can see the complete implementation that powers this password-protection server in the functions folder of the example-repo. The folder contains 5 files (written in TypeScript, but you can remove the types and rename to .js if you feel more comfortable with plain JavaScript):

  • _middleware.ts -> the middleware that intercepts all requests to our Pages site.
  • cfp_login.ts -> the function that handles POST request to the /cfp_login route.
  • constants.ts -> a few constants you can use to customize the service to your liking.
  • template.ts -> the HTML template for the login page.
  • utils.ts -> a couple of utility functions for encrypting passwords and working with cookies.

There is nothing too interesting going on in the constants.ts, template.ts and utils.ts files, so I'm going to focus on the other two:

_middleware.ts#

// functions/_middleware.ts

import { CFP_ALLOWED_PATHS } from './constants';
import { getCookieKeyValue } from './utils';
import { getTemplate } from './template';

export async function onRequest(context: {
  request: Request;
  next: () => Promise<Response>;
  env: { CFP_PASSWORD?: string };
}): Promise<Response> {
  const { request, next, env } = context;
  const { pathname, searchParams } = new URL(request.url);
  const { error } = Object.fromEntries(searchParams);
  const cookie = request.headers.get('cookie') || '';
  const cookieKeyValue = await getCookieKeyValue(env.CFP_PASSWORD);

  if (
    cookie.includes(cookieKeyValue) ||
    CFP_ALLOWED_PATHS.includes(pathname) ||
    !env.CFP_PASSWORD
  ) {
    // Correct hash in cookie, allowed path, or no password set.
    // Continue to next middleware.
    return await next();
  } else {
    // No cookie or incorrect hash in cookie. Redirect to login.
    return new Response(getTemplate({ withError: error === '1' }), {
      headers: {
        'content-type': 'text/html'
      }
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

As we talked about before, this function intercepts all requests to our Pages site. If you look at the body of the function, it's nothing more than a big if/else statement:

  • If the request includes a cookie with the correct authentication hash, or if the path is on the list of allowed paths (paths that you don't want to password-protect), or if the CFP_PASSWORD environment variable is not set, continue to the next middleware, which in our case means respond with the route we were intercepting.
  • Otherwise, respond with the contents of the getTemplate() function, which is the HTML template of the login page.

cfp_login.ts#

The other interesting component of the application is the cfp_login.ts function, which is yet another big if/else block:

// functions/cfp_login.ts

import { CFP_COOKIE_MAX_AGE } from './constants';
import { sha256, getCookieKeyValue } from './utils';

export async function onRequestPost(context: {
  request: Request;
  env: { CFP_PASSWORD?: string };
}): Promise<Response> {
  const { request, env } = context;
  const body = await request.formData();
  const { password } = Object.fromEntries(body);
  const hashedPassword = await sha256(password.toString());
  const hashedCfpPassword = await sha256(env.CFP_PASSWORD);

  if (hashedPassword === hashedCfpPassword) {
    // Valid password. Redirect to home page and set cookie with auth hash.
    const cookieKeyValue = await getCookieKeyValue(env.CFP_PASSWORD);

    return new Response('', {
      status: 302,
      headers: {
        'Set-Cookie': `${cookieKeyValue}; Max-Age=${CFP_COOKIE_MAX_AGE}; Path=/; HttpOnly; Secure`,
        'Cache-Control': 'no-cache',
        Location: '/'
      }
    });
  } else {
    // Invalid password. Redirect to login page with error.
    return new Response('', {
      status: 302,
      headers: {
        'Cache-Control': 'no-cache',
        Location: '/?error=1'
      }
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice that we're exporting a function called onRequestPost as opposed to the onRequest function of the previous file. This is because we want this route to react to POST requests to the /cfp_login path.

The body of the function compares the hash of the password provided by the user via the login form with the hash of the password in the CFP_PASSWORD environment variable. If they match, they've entered the right password, so we redirect them to the home page while also setting a cookie with the password's hash as the value.

Otherwise, we'll redirect to the home page with the ?error=1 query param set, which in our template we use to show an error message.

The cookie we set has an expiration time of one week by default (which can be customized in the constants.ts file). The cookie will be included on every subsequent request to our site, and as long as it has the correct value, it will pass the condition on the _middleware.ts function, which will serve the request page directly without asking for the password again.


Setting the Password#

The last thing we need to do is create the CFP_PASSWORD environment variable with the password we want to use to protect our site. You can do this on your Page's site Dashboard under Settings -> Environment Variables. You can set a different password for the Production and Preview environments if you want to.

Image description

Changing the Password#

Our simple authentication server doesn't have actual "sessions", so there's nothing to invalidate if you decide to change the CFP_PASSWORD environment variable with a different password.

Changing the password will cause the hash from the cookie to no longer match the hash on the server, which will in turn prompt the user for the new password the next time they try to access a page.


Running Locally#

To run your functions locally and test the password-protection on your own computer, you can use the wrangler CLI using npx:

npx wrangler pages dev build -b CFP_PASSWORD=password

Enter fullscreen mode Exit fullscreen mode

Notice that you'll need to pass the CFP_PASSWORD environment variable when running the CLI command. If you don't pass it, the site will be served but it will not be password-protected.


And that's all I've got!

I hope you find this article and the example project useful. If you give it a try on your own Pages site, please let me know how it goes in the comments!

Thank you for reading~ <3