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
- username: password
- password: concannon.tech
The code for the site above is available on GitHub.
The solution works in the following order for a browser making an initial request:
- https://password.concannon.tech has a CNAME record pointing to a Cloudfront distribution, so the request gets routed to Cloudfront
- Cloudfront has a trigger on the “Viewer Request” event which runs a Lambda function.
- The Lambda function checks to see if there is an
Authorization
header constructed with the correct username and password. - Since this is the browser’s first visit, there is no
Authorization
header, so the Lambda returns a401
response that contains thewww-authenticate: Basic
response header. - The browser sees the
www-authenticate
response header and knows to handle it by prompting the user for a username and password entry. - The user enters the correct username and password, and the browser sets the
Authorization: Basic xxxxxx
header and sends another request tohttps://password.concannon.tech
. - 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. - 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. - 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:
- Provision an SSL Certificate for your custom site domain with AWS Certificate Manager.
- Create an S3 bucket with a restricted access policy (no public access). Put a placeholder
index.html
file in it. - 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.
- 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
andauthPassword
variables to your own. - 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).
- 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 - 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);
};