Serverless: Free Basic Auth Password Protection of a Static Website

May 2021 ยท 6 minute read

All plug-and-play website password-protection solutions from hosting providers require payment at a usable scale.

I recently built a wedding website. Due to the semi-private nature of the event details, I decided to make it password-protected. I wanted a simple solution: a username and password prompt that would be performant, reliable, easy to use across all platforms, and free to implement.

I scoured the internet for free static website authorization solutions, but ultimately I had no luck. All plug-and-play website password-protection solutions from hosting providers require payment at a usable scale. Not a single plug-and-play password-protection solution exists that is highly performant, highly available, and free to scale. Even Cloudflare and Netlify both require you to pay for their password-protection services on static sites.

Design

To my surprise, I found zero examples of header-based authorization that would restrict access to all the files of a static website.

I’ve hosted a few static sites with S3 in the past, and I’ve written Lambda Authorizers in the past, so I figured it couldn’t be too hard to combine those two things into an extremely fast, highly-available, and inexpensively-scalable (free) solution. To my surprise, I found zero examples of header-based authorization that would restrict access to all the files of a static website.

I found bits and pieces that partially worked. Plenty of guides exist to host a single-page app on Cloudfront and S3. I found single-page-app Lambda authentication examples, but those don’t work without request-rewriting tricks at the edge (more on that later). There were plenty of server-side header-based auth examples for static sites, but adding a small compute instance slows down performance and reduces availability. I don’t want to pay for compute power when I can build 99.9% availability with edge-location latency for free with a serverless architecture.

So, I pieced together code examples, configuration details, and serverless patterns from here and there in order to build a solution myself.

I don’t want to pay for compute power when I can build 99.9% availability with edge-location latency for free with a serverless architecture.

Solution

You can find my working example deployed at https://password.concannon.tech

The code for the site above is available on GitHub.

A diagram that visually represents the steps listed in this Solution section

A crude hand-drawn diagram of the solution

The solution works in the following order for a browser making an initial request:

  1. https://password.concannon.tech has a CNAME record pointing to a Cloudfront distribution, so the request gets routed to Cloudfront
  2. Cloudfront has a trigger on the “Viewer Request” event which runs a Lambda function.
  3. The Lambda function checks to see if there is an Authorization header constructed with the correct username and password.
  4. Since this is the browser’s first visit, there is no Authorization header, so the Lambda returns a 401 response that contains the www-authenticate: Basic response header.
  5. The browser sees the www-authenticate response header and knows to handle it by prompting the user for a username and password entry.
  6. The user enters the correct username and password, and the browser sets the Authorization: Basic xxxxxx header and sends another request to https://password.concannon.tech.
  7. This time, the Lambda function sees the correct Authorization: Basic xxxxxx header in the inbound request, so it continues to process the request by looking at the request path.
  8. If the request does not end in a filename extension, the Lambda function modifies the request to end in /index.html. If the request already had a filename extension, the Lambda function does not modify anything. The Lambda function then exits.
  9. Cloudfront handles the request, which at this point always ends in a filename extension (such as /index.html). Cloudfront serves the requested objects from cache or fetches them from S3.

Learning by Doing

The steps above seem very logical and straightforward, but configuring the details in S3, Cloudfront, and Lambda required a lot of trial-and-error. To implement a working solution that adheres to the pattern described above, do these things in order:

  1. Provision an SSL Certificate for your custom site domain with AWS Certificate Manager.
  2. Create an S3 bucket with a restricted access policy (no public access). Put a placeholder index.html file in it.
  3. Configure a Cloudfront distribution for the S3 bucket and restrict access to the bucket to Cloudfront-only. Configure the distribution as a CNAME for your custom domain in (1) and use the certificate from AWS Certificate Manager.
  4. Configure a Lambda function in US-East-1 with the index.js auth handler, and assign it the Edge/Cloudfront trigger role (search for Edge and you’ll find it). Change the authUser and authPassword variables to your own.
  5. Revisit your Cloudfront distribution cache settings to update the Lambda@Edge to trigger your Lambda function on “Viewer Request” (this is important, because otherwise it stays on “Origin Request” and doesn’t work).
  6. Test everything - you should be able to go through the authentication process to fetch the placeholder index.html file at the root of the distribution
  7. Copy your site files to the S3 bucket, and you’re done!

Stay Encouraged

I specifically avoided linking to outside references in this post because there is so much bad, but related, information on this topic on the internet. Instead of pointing to resources that may change or disappear in the future, I’ve instead provided a blueprint of very specific steps that you can reference.

It’s important to know exactly how the authorization flow works, how the pieces fit together, and why they need to fit together this way. I hope you find it useful.

Thanks for reading!

P.S.

Don’t use this pattern as an extremely long-term solution, or to protect critical secrets or extremely sensitive information. I think it is perfectly suited to my use-case of protecting a wedding website, or a community information forum, or some other website where the sensitivity of the guarded information is temporal in nature.

Since all the users share the site credentials, and there’s no rate-limiting protection described here, you’d be setting yourself up for trouble if this was the extent of protection on any kind of highly-sensitive information.

References

S3 Bucket Policy

{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity {value}"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{bucket-name}/*"
        }
    ]
}

Lambda Function

'use strict';

// use path library to filter requests for pages vs assets
const path = require('path');

exports.handler = (event, context, callback) => {

    // Get request and request headers
    const request = event.Records[0].cf.request;
    const headers = event.Records[0].cf.request.headers;
    headers.authorization = headers.authorization || [];
    
    console.log("Request: " + JSON.stringify(request));

    // Configure authentication
    const authUser = "password";
    const authPass = "concannon.tech";

    // Construct the Basic Auth string
    const authString = 'Basic ' + new Buffer(authUser + ':' + authPass).toString('base64');

    // Require Basic authentication
    if (headers.authorization.length == 0) {
        returnUnauthorizedResponse(callback);
    }
    else if (headers.authorization[0].value != authString) {
        returnUnauthorizedResponse(callback);
    }

    // Extract the URI path from the request (removes trailing '/' when present)
    const parsedPath = path.parse(request.uri);
    let newUri;

    if (parsedPath.ext === '') {
        // fetch index.html for requests with no file extensions
        newUri = path.join(parsedPath.dir, parsedPath.base, 'index.html');
    }
    else {
        // preserve the uri when it has file extensions
        newUri = request.uri;
    }

    request.uri = newUri;

    // Continue request processing if authentication passed
    return callback(null, request);
};

function returnUnauthorizedResponse(callback) {
    const body = 'Unauthorized';
    const response = {
        status: '401',
        statusDescription: 'Unauthorized',
        body: body,
        headers: {
            'www-authenticate': [{ key: 'WWW-Authenticate', value: 'Basic' }]
        },
    };
    console.log("Responding with: " + JSON.stringify(response));
    callback(null, response);
};