This got started when my colleaque came asking how to implement a simple shared password authentication for a web service that must be exposed to public but should be kept off from random browsers. In this context public could be understood as public internet or intranet of a large company. My initial reaction was, this is an easy thing to do with ALB and Cognito. Unfortunately this combination wasn’t supported at eu-north-1 -region. Ok, but there must be tons of examples how to add HTTP Basic Auth to ALB with Lambda … but my googling produced no usable results.

Now Go Build

To build the most simple setup that could also be used as a foundation for real-life use-cases I needed

  • Public ALB listening on HTTPS.
  • HTTPS listener needs an ACM certificate.
  • For DNS verification of the certificate there must be an R53 hosted zone.
  • HTTP Basic Auth logic implemented as Lambda -function registered to ALB.
  • Public and private backends implemented as static responses from ALB.

Architecture

Above specs become then implementation show in below diagram. To get all of this into single deployable file I created a SAM template as it allowed me to include also short lambda code inline. It is assuming you have the VPC with at least subnets and Route53 hosted zone available. Other resources (show in bold) are created from the template.

Template takes parameters for ALB deployment

  • VPC ID
  • 2 (or more) public subnets where ALB is to be deployed.
  • Route53 hosted zone ID & name.

And you can also set Basic Auth settings (or accept defaults)

  • Username (foo)
  • Password (bar)
  • Authenticated URL prefix (/auth)

I didn’t want to pretend this would be super safe and use NoEcho for password input as it would be anyways visible in ALB routing configuration, base64 encoded, and in clear text in Lambda function environment settings.

ALB Routing

ALB is configured with URL path based routing as shown in below screenshot. The First rule (100) will apply for /auth/* AND when the Authorization header is present with the correct value of base64 encoded username:password. When this is true, ALB will return “private” content. In real life this would be forwarded to private backend.

The Second rule (200) applies for same path as the first, but when Authorization header is missing or is not valid. Then reply is generated from Basic Auth Lambda -function included in the template.

The Default rule applies when neither previous rules did not. This is the public, unauthenticated content for all other URL paths, except /auth/*. In real life this could be a simple static response or a static web page from S3.

Basic Auth Lambda

Lambda function triggering authentication in browser is very simple. It only needs to do 2 things. First check if Authorization -header is present and has correct username and password. On success do a redirect to reload page. As header is now present, ALB will show private content instead of forwarding to this function. If authentication fails due to missing or incorrect header, return HTTP status 401 (Unauthorized) that will trigger authentication dialog in browser.


      def lambda_handler(event, context):
          # Get username and password from environment
          username = os.environ['USERNAME']
          password = os.environ['PASSWORD']

          # Get authorization header
          headers = event['headers']
          auth_header = headers.get('authorization', '')

          # Validate credentials
          if auth_header.startswith('Basic '):
            auth_base64 = auth_header.split(' ')[1]
            auth_decoded = base64.b64decode(auth_base64).decode('utf-8')
            user, pwd = auth_decoded.split(':')
        
            if user == username and pwd == password:
              # Authentication successful
              return {
                'statusCode': 302,
                'statusDescription': '302 Redirect',
                'isBase64Encoded': False,
                'headers': {
                  'Content-Type': 'text/plain',
                  'Location': event['path']
                },
                'body': ''
              }

          # Request Basic Authentication if failed or no header
          return {
            'statusCode': 401,
            'statusDescription': '401 Unauthorized',
            'isBase64Encoded': False,
            'headers': {
              'WWW-Authenticate': 'Basic realm="User Visible Realm"',
              'Content-Type': 'text/plain'
            },
            'body': 'Unauthorized'
          }

Configuration Caveat

It might feel tempting to change username and password by editing Lambda environment. But it doesn’t work like that because the same values must be configured into ALB routing configuration. When you want to change authentication settings, do it by updating the Cloudformation stack. This will keep environment settings and routing configuration in-sync.

Lessons Learned

Like every good developer, I have enabled Amazon Q in my Visual Studio Code. At first this seemed like a very good idea and I started to get many good recommendations making the template creation very smooth process. Problems started when I tried to deploy the template … It turned out that AWS::ElasticLoadBalancingV2::ListenerRule is very easy to mis-configure and errors from Cloudformation are not the most helpful. Problem is every Action and Condition has it’s own configuration block. And Q was very convincing creating various hallucinations for listener configuration.

The Reason why we began in the first place was missing ALB and Cognito integration at eu-north-1. We also reached out to AWS to check if this was on near term roadmap. And as it wasn’t, we did this hack to keep our project going. About week later we found the feature was added to most (all?) of missing regions, including eu-north-1. It is difficult to say which is better, flooding announcement with new regions enabled for service, or not doing it. That said, I didn’t find any news, other than document being updated, about ALB/Cognito being expanded to new regions.