DEV Community

Cover image for Password Protection for Cloudflare Pages
Maxi Ferreira
Maxi Ferreira

Posted on • Edited on

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 🎉

⚠️ Note: You might want to update your Cloudflare project settings to be "Failed closed" as well. Otherwise, your site will be unprotected if you reach your daily limit of Function requests. Thanks Thomas for letting me know about this!

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

Top comments (12)

Collapse
 
juliankoehn profile image
Julian

Thanks @charca, saved a bunch of time <3

Collapse
 
spaut profile image
Ron Pritchard

Hey Maxi, thanks for writing this tool and publishing it. It's a lifesaver.

Quick question: is there a way to preserve the navigation after auth? It looks like the script always redirects to the root. I'm wondering if I can go directly to something like domain.com/some-page after authenticating.

Thanks,
Ron

Collapse
 
charca profile image
Maxi Ferreira

Hey @spaut, sorry for the delay. I'm glad you've found the script useful!

Yes, that's definitely possible. I've updated the example repo with that behavior, so it now redirects to the page you were trying to access after login. You can see the diff where this feature was implemented here.

Collapse
 
gusdn22 profile image
Deviosa

Hey Maxi, this is so useful. Thanks for sharing and explanation. May I just ask because I noticed that URLs that ends with an extension such as PDF aren't working. The PDF file did not open after authenticating but instead just stayed on the authentication page.

Collapse
 
kennyray profile image
Ken

When I try this with Next.js 14 w/ Typscript I'm getting an error with template.ts that I've gone round and round with. Seems like no matter what syntactical change I make, it breaks something else. I changed to arrow function and then the HTML comes out as a string rather than HTML so doesn't render. I'm sure this is a simple adjustment, but it is elluding me.

Warning: React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: object.
⨯ Error: Unsupported Server Component type: Module
at stringify ()
at stringify ()

Collapse
 
wbtk profile image
Black White

maybe
//cfp_login
const hashedCfpPassword = await sha256(env.CFP_PASSWORD || '');
const redirectPath = (redirect && redirect.toString()) || '/';

//utils
import { CFP_COOKIE_KEY } from './constants';

export async function sha256(str: string | undefined): Promise {
if (str === undefined) {
return '';
}

const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return Array.prototype.map
.call(new Uint8Array(buf), (x) => ('00' + x.toString(16)).slice(-2))
.join('');
}

export async function getCookieKeyValue(password?: string): Promise {
const hash = await sha256(password);
return ${CFP_COOKIE_KEY}=${hash};
}

Collapse
 
kidino profile image
Iszuddin Ismail

I just got started with Cloudflare Pages. Do you think it is possible to extend this with D1 to store users table and their passwords?

Collapse
 
phong profile image
Nguyễn Hữu Phong

Thank you so much for this post. I'm not actually a developer but I can understand almost everything of your explaination.

Collapse
 
closingtags profile image
Dylan Hildenbrand

Great post! I assumed this was possible but glad to see that I won't have to reinvent the wheel when attempting to implement it myself.

Collapse
 
tigr profile image
Tigran Sargsyan

The demo says "Incorrect password, please try again." when I try to use the password "test".

Collapse
 
charca profile image
Maxi Ferreira

Thanks for calling that out! I've fix it and updated the article. The password is password.

Collapse
 
mariocarabotta profile image
mariocarabotta

Hi Maxi, thank you for sharing this, I might implement it for my website.
Is there an easy way to password-protect individual pages?

Thank you!