Password Protection for Static Websites on AWS S3

I recently needed to share a static website with a third-party and secure the content against access by unauthorised users. The website should only be viewable with a username and password. Website files were static content - so could be served from a CDN-like service. All content must be served over HTTPS on a custom domain.

Here’s how I accomplished this on AWS…

AWS Architecture

AWS’s object store (S3) supports serving static web content directly from user buckets. If static web access is enabled - external users can access bucket files via a web browser. S3 handles index document routing and 404 responses. This works perfectly for serving public web content - but S3 does not support protecting bucket files behind a username and password. It also does not support HTTPS with custom domains.

Luckily, AWS has a CDN service (CloudFront) which can fix all those issues. CloudFront supports custom domains (with HTTPS-only traffic enforced) and can proxy content directly from private S3 buckets. Buckets can be configured to be only accessed via CloudFront (rather than directly using the bucket hostname). But what about supporting HTTP authentication for all CDN content?

CloudFront does not natively support HTTP authentication. However, this can added using CloudFront Functions. CloudFront Functions allows user-provided serverless functions to inspect and modify HTTP request and responses for CDN content. Using a custom CloudFront function we can manually enforce HTTP authentication for all requests.

Here is the complete CloudFormation template for this approach.

Read on for an explanation of the Cloud Formation resources and configuration used…

Private S3 Bucket

This snippet creates the private S3 bucket which will contain the static website files.

SiteBucket:
  Type: AWS::S3::Bucket
  Properties:    
    AccessControl: Private

Ensure AccessControl is set to private. Bucket contents will only be accessible via the CDN. S3 will not directly expose the contents as a static website with public access.

Bucket Access Policy

This bucket policy allows read-only access to all files in the private S3 bucket from a CloudFront distribution.

SiteBucketPolicy:
  Type: "AWS::S3::BucketPolicy"
  Properties:
    Bucket: !Ref SiteBucket
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Principal:
            Service: "cloudfront.amazonaws.com"
          Action: 's3:GetObject'
          Resource: !Sub "${SiteBucket.Arn}/*"
          Condition:
            StringEquals:
              # CF distribution ARNs have to be manually constructed
              AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}"

The SiteDistribution reference is the logical ID for the CloudFront distribution defined later on. There is no attribute for this property - it must be constructed manually.

CloudFront Distribution

The CloudFront Distribution is configured to serve content from the private S3 bucket. The Origins list has a single item - which uses the domain name for the private S3 bucket. It attaches the access control policy (defined afterwards) to provide access to the bucket (OriginAccessControlId).

SiteDistribution:
  Type: "AWS::CloudFront::Distribution"
  Properties:
    DistributionConfig:
      Origins:
          # Use regional, rather than global, domain for S3 bucket.
        - DomainName: !GetAtt SiteBucket.RegionalDomainName
          Id: SiteBucketWebsite                    
          S3OriginConfig:
          	# This empty property is necessary 🤷
            OriginAccessIdentity: ""
          OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
      Enabled: true
      DefaultRootObject: index.html
      DefaultCacheBehavior:
        # Default cache policy to disable caching.
        CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
        TargetOriginId: SiteBucketWebsite
        AllowedMethods:
          - HEAD
          - GET
          - OPTIONS
        Compress: true
        # Enforce HTTPS only
        ViewerProtocolPolicy: redirect-to-https
        FunctionAssociations:
          - EventType: viewer-request
            FunctionARN: !GetAtt SiteAuthFn.FunctionMetadata.FunctionARN
      PriceClass: PriceClass_100

The distribution origin configuration uses the regional, rather than global, S3 bucket hostnames. This makes the site work immediately - rather than waiting (up to 24 hours) for bucket names to propagate across AWS regions. See here for more details.

The ViewerProtocolPolicy: redirect-to-https property enforces HTTPS-only access to the CDN content.

CloudFront Functions are attached to a distribution using the FunctionAssociations property. There is a single function attached (SiteAuthFn) which is executed with all incoming HTTP requests.

CloudFront Origin Access Control

This resource allows CloudFront to send signed requests to the S3 bucket - which allows the S3 bucket policy to provide access to private resources.

SiteOriginAccessControl:
  Type: AWS::CloudFront::OriginAccessControl
  Properties:
    OriginAccessControlConfig:
      Name: "Site Bucket Access Control"
      OriginAccessControlOriginType: s3
      SigningBehavior: always
      SigningProtocol: sigv4

CloudFront Function To Enforce HTTP Auth

The CloudFront Function source code is included directly in the Cloud Formation file.

The function implements HTTP Basic Authentication by checking requests have the correct auth header value. CloudFront Functions have runtime restrictions (must complete in < 1 ms, no access to AWS services or external network) which forces us to include the static authentication string within the function source code.

The Base64-encoded version of the authentication string (in the format: username:password) is passed via a Cloud Formation parameter (Base64UserPass).

SiteAuthFn:
  Type: AWS::CloudFront::Function
  Properties:
    AutoPublish: true
    FunctionCode: !Sub "
      function handler(event) {
        var authHeaders = event.request.headers.authorization;
        var expected = 'Basic ${Base64UserPass}';

        if (authHeaders && authHeaders.value === expected) {
          return event.request;
        }

        var response = {
          statusCode: 401,
          statusDescription: 'Unauthorized',
          headers: {
            'www-authenticate': {
              value: 'Basic realm=\"Enter credentials for this super secure site\"',
            },
          },
        };

        return response;
      }"
    FunctionConfig:
      Comment: "Add HTTP Basic authentication to CloudFront"
      Runtime: cloudfront-js-1.0
    Name: BasicAuthFn

Requests with the correct header value are passed along without modification. If the username or password are incorrect - a custom HTTP response is returned (which forces the authentication pop-up in the browser). This stops the original request from being forwarded to the private S3 bucket.

Deploy & Test

Here is the complete CloudFormation template for this feature.

If you deploy this stack template and fill the S3 bucket with static web content, it will be available at the CloudFront distribution hostname (over HTTPS) when you authenticate using the chosen username and password.

AWS CLI Commands
  • Deploy the Cloud Formation stack to AWS.
aws cloudformation deploy --template-file example.cf.yaml --stack-name secure-static-website --parameter-overrides Base64UserPass=$B64_USER_PASS

The Base64UserPass parameter must be a Base64-encoded string from the source template: username:password as per the HTTP Basic Authentication RFC.

  • Add some static web content to the bucket (like an index.html file).

  • Retrieve the CloudFront distribution hostname from the Stack Outputs:

aws cloudformation describe-stacks --stack-name secure-static-website --query "Stacks[0].Outputs[?OutputKey=='StaticWebsiteHostname'].OutputValue" --output text
  • Open this hostname in a web browser to serve the static content. This should prompt for the username and password details as specified above. This will only be accessible using HTTPS (requests using HTTP will be redirected).

Alternative Approaches

AWS often has multiple solutions for the same problem - here are two different approaches…

  • AWS has a newer service called Amplify which can serve static web content from a CDN. It supports password-protection for public content (without using custom CloudFront functions).
  • Lambda@Edge is the previous-iteration of serverless functions for CloudFront. It has less restrictive runtime requirements - allowing us to access other AWS services. This could be used to dynamically retrieve the username and password at runtime, rather than hardcoding.