Skip to content

Add HTTP Basic Authentication to CloudFront Distributions

I have a static site hosted in an S3 bucket (old 8mm family videos) that I wanted to make available to a limited number of folks, but I didn’t want to bother with worrying about doing any sort of federated authentication or limiting via IP or VPN access. A very simple yet effective means of doing this is to just use HTTP Basic Authentication, where the browser itself will prompt the visitor for a username and password and pass it to the server for authentication.

The site in the bucket is served by a CloudFront distribution. There’s no option in either S3 or CloudFront to enable HTTP Basic Auth, but luckily it can be done by writing a function that is executed on each request for an asset in the distribution.

There are several tutorials on the web for implementing this using Lambda@Edge functions, but not too long ago AWS introduced CloudFront Functions, which run at the edge location itself as opposed to at the regional edge location (one layer deeper) for Lambda@Edge. Additionally, CloudFront Functions are ⅙ the price of Lambda@Edge executions, and as a bonus you get 2,000,000 invocations for free in the Free Tier, hooray! (This site would probably only ever incur pennies in function costs anyway, but hey.) And frankly, implementing the CloudFront Function for this use case is easier IMO than the corresponding L@E implementation. You can do less with CF Functions than L@E, but enough for this particular problem. So on to the code!

TOC:

  1. HTTP Basic Authentication Refresher
  2. CloudFront Function Basic Auth Check Code
  3. Hooking Up the Function

HTTP Basic Authentication Refresher

Spacing on the details of how HTTP Basic Auth works? Yep, me too. Luckily, it’s quite simple.

Like many authentication schemes in HTTP, credentials are passed in the Authorization header of the HTTP request. In the Basic auth mode, credentials are simply a combo of [username]:[password], and base64-encoded, with “Basic” prepended to indicate the challenge type. So if a user’s name was john and his password was foobar, the Authorization header contents would look like this:

Basic am9objpmb29iYXI=

“Woah woah, base64 is not encryption and passing credentials around like that is super sketchy!” You are correct, dear reader, which is why you absolutely must have any connections utilizing HTTP Basic Auth be sent over HTTPS, or you’ll be leaking credentials left and right. But if you’ve got your SSL/TLS setup all worked out you should be fine, and you’re probably not protecting state secrets if all you are opting for is Basic auth, right? RIGHT??? 🙂

What a proper Authorization header looks like in dev tools.

On the server’s end, it will check for the existence of the Authorization header, pull out the credentials after it decodes the base64 string, and compare it to known matches. If the client did not pass an Authorization header, then the server will respond with a 401 error and a WWW-Authenticate header with the value set to Basic, indicating that the browser must give the Basic auth challenge to the user before performing further requests. That WWW-Authenticate: Basic challenge is what causes the browser to pop up the username/password combo box to the user and submit future requests in that session with the same credentials.

Inspecting the challenge header that comes back telling the browser to open the credentials dialog. The “realm” portion is helper text you can include for the user, but not all browsers will display it.

For a more detailed overview of HTTP authentication, along with some interesting additional options, see MDN’s HTTP Authentication page.

CloudFront Function Basic Auth Check Code

So what kind of function are we going to write to teach CloudFront the nifty Basic auth trick? It’s quite simple, actually. CloudFront allows us to hook into either the original request or subsequent response portions of the pipeline, and modify or replace the HTTP request/response objects.

We’ll check the original request object for the proper Authorization header, and validate it if we find it. If not, we’ll cut the request process short and send back the 401 / WWW-Authenticate: Basic challenge to get credentials from the user. The credentials in this case are hard-coded, but again hopefully you’re using this in a light security needs scenario. RIGHT???

function handler(event) {
  var authHeaders = event.request.headers.authorization;

  // The Base64-encoded Auth string that should be present.
  // It is an encoding of `Basic base64([username]:[password])`
  // The username and password are:
  //      Username: john
  //      Password: foobar
  var expected = "Basic am9objpmb29iYXI=";

  // If an Authorization header is supplied and it's an exact match, pass the
  // request on through to CF/the origin without any modification.
  if (authHeaders && authHeaders.value === expected) {
    return event.request;
  }

  // But if we get here, we must either be missing the auth header or the
  // credentials failed to match what we expected.
  // Request the browser present the Basic Auth dialog.
  var response = {
    statusCode: 401,
    statusDescription: "Unauthorized",
    headers: {
      "www-authenticate": {
        value: 'Basic realm="Enter credentials for this super secure site"',
      },
    },
  };

  return response;
}

Relatively straightforward, but let me address a few basics for context.

All CloudFront functions must be named handler, and they must be written in JavaScript. (If you want to write them in Python as well you can opt for Lambda@Edge functions.) Interestingly, the JS that CF Functions support is ES5, plus a smattering of ES6-9 features. For example, you can use string template literals are supported, but notably const and let are not, so be ready to dive back with your old frenemy var. Also, CF Functions are limited to a 1 ms run time, so make sure you’re being conscientious about the performance of the code you write.

The handler function is passed an event object which has, among a few other things, request and response properties with corresponding details. We can examine these properties and modify them as appropriate. When intercepting an original request, you can modify the request object and then return the request object to allow it to continue. Or you can return the response object instead to short-circuit the original request/response and not hit your origin. We do both in the code above. I’m not sure, but I’m guessing that the CF Function engine is looking at the shape of the returned object to determine if you returned it a request or response.

In this little function, we first grab what we hope is the Authorization header, and we also prepare a hard-coded version of what we would expect the correct credentials to be (our expected variable). Then we check if the authHeaders variable is defined and if it exactly matches what we’re looking for. If it does then we pass along the original request unmodified because the user has shown they can access it.

In any other failure mode we prompt for credentials. We do that by constructing our own new response object, indicating the proper status code and WWW-Authenticate header, and return it from the function.

Now you could definitely code golf this particular little function if you wanted to, but hopefully this version of the code is readable and easy to understand.

Hooking Up the Function

The AWS documentation for creating, testing, and associating the function with a CloudFront distribution is solid. If using the AWS console, you go to “Functions” in the left sidebar of the CloudFront pages, create a new function, and copy and paste your code in. You can save changes to the code, test it in the same console page, and publish it when you’re happy. Then you go back to your CloudFront distribution to associate the published function with the distribution and event type (either “viewer request” or “viewer response”). If you read the docs it’ll walk you through it step by step.

That being said, let me offer just a few tips that I uncovered as I went through the process.

  • All references to headers in the JS code must be lower-cased. The docs mention this, but I missed it the first time and couldn’t figure out why my code was breaking. That’s why you see 'www-authenticate' in the code above for the response instead of the proper casing.
  • You can still use console.log() in your code to test inside of the CloudFront console online. Useful for simple debugging.
  • You can only associate one CloudFront Function with any one distribution and event type. So if you want to do two things when a request comes in, you have to put both actions into one function, instead of creating two functions and associating them both with a request in a distribution. (I needed to add /index.html to a few requests, and ended up doing that in my same auth function, but not till after I had written, tested, and published it as a separate function.)

There you have it! After hooking up your function to your distribution (and sometimes waiting a few minutes for a cache invalidation), you should now have a pretty straightforward credentials dialog pop up whenever you go to access your site. Cheaper and slightly faster than Lambda@Edge, though less powerful, I found this a perfect little point solution to a particular problem. Hopefully this helps you out too.

Published inGeneral

5 Comments

  1. Roliverio Roliverio

    Works nice, however, the instructions on how to properly encode the user/pass combination are a little tricky as you have to encode it without newline.

  2. Caleb Caleb

    Thanks for the article!

    I ran into a small issue with your CloudFront function regarding CORS and pre-flight checks. Even with the proper CORS settings on both the CloudFront origin request policy and on the S3 bucket it was attached to, we found that requests to the bucket from an external site were being blocked.

    The issue seemed to be that the site was making an OPTIONS request without the Authorization header (which is the correct pre-flight behavior according to the W3 spec), and this was being blocked by the CloudFront function. The obvious fix was to exclude OPTIONS requests from the auth check, which I did using this at the top of the handler function:

    if (event.request.method === “OPTIONS”) { return event.request; }

    Hope that helps for anyone who stumbles across this article and runs into the same issue.

  3. Thanks for this article. Works like a charm.

    Some additions:
    – You can omit the “realm” property from the www-authenticate header. Modern browsers don’t show the text because it’s seen as a security risk.
    – When you use the CloudFront function test tool, only use lowercase characters in the header name. AWS fails on uppercase characters with a very generic error message.

  4. mikeful mikeful

    If you add this to response side, remember to change return to event.response and add “Via” and “Warning” headers from event.response to generated 401 response to prevent error 502 about read-only headers.

Leave a Reply

Your email address will not be published. Required fields are marked *