Networking & Content Delivery

Authorization@Edge using cookies: Protect your Amazon CloudFront content from being downloaded by unauthenticated users

Enterprise customers who host private web apps on Amazon CloudFront may struggle with a challenge: how to prevent unauthenticated users from downloading the web app’s source code (for example, React, Angular, or Vue). In a separate blog post, you can learn one way to provide that security using Amazon Lambda@Edge and Amazon Cognito, with an example implementation based on HTTP headers.

In this article, we focus on the same use case, sharing an alternate solution that also uses Lambda@Edge and Cognito but is based on HTTP cookies. Using cookies provides transparent authentication to web apps, and also allows you to secure downloads of any content, not just source code.

Overview: Preventing unauthorized content download

Many web apps today are created as a Single Page Application (SPA). A SPA is a single HTML page—index.html—bundled together with JavaScript and CSS. JavaScript is at the core of every SPA, and there are several JavaScript frameworks and libraries to help developers create SPAs, including React, Angular, and Vue.

Companies can choose to host corporate internal SPAs publicly on Amazon CloudFront, using Amazon Simple Storage Service (S3) as the origin. By doing this, companies leverage the advantages of serverless hosting: low costs and no servers to manage. Hosting the app in the cloud also makes it easy for users to access it, especially on a mobile device where they might not be connected to the corporate network. To use the app, they just need internet access.

The downside of publicly hosting an internal SPA is that potential attackers can also download the SPA, not just the intended users. While attackers can’t sign in, they can analyze the source code to learn what the SPA does and which backend API endpoints it connects to. They might even spot a security bug that you’re unaware of.

One common mitigation to thwart analysis of how a SPA works is to obfuscate (“uglify”) the SPA’s source code. The SPA will still run and perform the same tasks but it’s very hard for humans to step through it. However, this is not foolproof security against determined attackers.

In this blog post, we explore another mitigation: Using Amazon Lambda@Edge to prevent unauthenticated users from even downloading the SPA’s source code. The earlier blog post explains one way to do this, using HTTP headers. With the HTTP headers solution, you must separate your SPA into public and private parts, you must set HTTP headers while downloading the private part, and you must have code that manages all of that. In our solution, we use cookies instead of headers, which makes the functionality transparent to your SPA. Lambda@Edge sets the cookies after sign-in and browsers automatically send the cookies on subsequent requests. That means that the only change you need to make to your SPA is to configure it so that it recognizes the cookies.

In fact, this solution can be used generically to add Cognito authentication to CloudFront web distributions. For example, you can secure CloudFront S3 bucket origins that have private content, such as images.

The building blocks of the sample solution

For the sample solution, we use the following main building blocks:

  • A private S3 bucket to host the SPA.
  • A CloudFront distribution to serve the SPA to users.
  • A Lambda@Edge function to inspect the JSON Web Tokens (JWTs) that are included in cookies in incoming requests. The function either allows a request or redirects it to authenticate, based on whether the user is already signed in.
  • A Lambda@Edge function that sets the correct cookies when a user signs in. The user’s browser automatically sends these cookies in subsequent requests, which makes the sign-in persistent across requests.
  • A Cognito user pool with a hosted UI setup that allows users to complete sign-in.

The solution uses the standard “Authorization code with PCKE” OAuth2 grant, which is supported by Cognito.

Deploying the sample solution

You can deploy this solution from the AWS Serverless Application Repository. The provided solution deploys with all of the building blocks integrated together, including a sample SPA, implemented in React (you can replace this with your own SPA).

The solution has a number of parameters that you can safely leave set to the default values. If you provide an email address, the solution creates a user in the user pool so that you can sign in and try out the example.

The deployment can take about 20 minutes because it includes a CloudFront distribution that takes a short time to propagate. After deployment, in the resulting CloudFormation stack, navigate to the Outputs tab. In the list, click the value for WebsiteUrl to open the example SPA in your web browser. You’ll be prompted to sign in.

If you like, you can deploy the solution using CloudFormation instead, as we’ll explain later.

The sample solution, step by step

To understand how all the building blocks work together, let’s look at the scenario of a user who tries to access the SPA but hasn’t authenticated yet.

The following are the three main parts to the solution’s flow:

Part 1: The user attempts to access the SPA and a Lambda@Edge function is invoked that redirects to Cognito for authentication.

Part 2: The user authenticates and is redirected back to CloudFront. CloudFront verifies authentication using a Lambda@Edge function, and then sets cookies with JWTs.

Part 3: The user’s browser follows the redirect and reattempts to access the SPA. Lambda@Edge validates that access is now authorized, by checking the JWTs in the cookies.

Now we’ll explain each part of the solution in more detail.

 

Part 1 – Sign in attempt

  1. The user tries to access the SPA. The user’s browser attempts to download the index.html file from the S3 bucket, accessed through CloudFront.
  2. The CloudFront distribution has been configured with a “check auth” Lambda@Edge function that inspects every incoming request. The function checks the cookies in the request to see if they contain the user’s authentication information. The information isn’t there because the user hasn’t signed in yet, so the function redirects the user to sign in at the user pool’s hosted UI.
  3. CloudFront responds with a redirect to the user pool’s hosted UI instead of sending back the index.html file that the user requested. The CloudFront response includes a state parameter that contains the originally-requested URL and a nonce. A nonce is a cryptographic construct that prevents Cross Site Request Forgery. The nonce is also stored in a cookie. (Besides the nonce, another cryptographic construct is involved for the PCKE part of the OAuth flow. For brevity, we’ll skip explaining that here. For more information, see the code on GitHub.)
  4. The user’s browser opens the user pool’s hosted UI, where the user signs in.

 

Part 2 – Authentication and verification

  1. After the user signs in to the user pool’s hosted UI, Cognito redirects the user back to the SPA with a specific URL, “/parseauth”. The redirect contains a query string that includes an authorization code. It also contains the state parameter that was passed in the redirect to Cognito in Part 1.
  2. The user’s browser follows the redirect by accessing the “/parseauth” URL and includes the query string. The cookie with the nonce (set in Part 1) is passed along with the request.
  3. The CloudFront distribution has been configured with a “parse auth” Lambda@Edge function that is configured to handle requests to “/parseauth”. This function gets the authorization code and state parameter from the query string of the request. Then it compares the nonce in the state parameter with the nonce in the cookie to make sure that they match.
  4. The Lambda@Edge function invokes the user pool’s token API to exchange the authorization code in the request for JWTs. The JWTs are stored in cookies in the HTTP response. The Lambda@Edge function extracts the originally-requested URL from the state parameter (included in the query string) and prepares a redirect to that URL.
  5. CloudFront returns the redirect to the user’s browser, setting cookies with JWTs.

 

Part 3 – Redirect and access

  1. The user’s browser, following the redirect, tries again to download the SPA’s index.html file. This time the request includes cookies with JWTs that were set earlier.
  2. The “check auth” Lambda@Edge function—the same one that executed in Part 1—checks the cookies, and now the cookies include the JWTs. To validate these JWTs, the function requires the JSON Web Key Set (JWKS) from the user pool.
  3. The Lambda@Edge function fetches the JWKS from the User Pool and validates the JWTs. If the JWTs are valid, the function responds to CloudFront with permission to allow the request to continue to Amazon S3. Note that the fetched JWKS is cached in the Lambda function (outside the handler), so that it can be reused and won’t need to be fetched in subsequent invocations of the function.
  4. Now that the request has permission, CloudFront returns the requested page. The user successfully accesses the SPA!

After these steps are completed, the user is authenticated, and the user has downloaded the index.html. Now the user’s browser can fetch all of the JavaScript and CSS files (for example, “bundle.js”). Because the user’s browser now automatically sends the cookies with requests, the requests succeed seamlessly for each requested file—as shown in the diagram for Part 3—so that the user can download the whole SPA.

Let’s look in more detail at some key aspects of the solution, starting with how you configure CloudFront and Lambda@Edge to implement it.

Configuring CloudFront and Lambda@Edge

CloudFront and Lambda@Edge functions are integral parts of our solution. The following sections explain more about how the Serverless Application Model (SAM–a CloudFormation “Transform”) template sets up and configures them to support the solution.

Configuring the CloudFront distribution with Lambda@Edge

To use Lambda@Edge functions in CloudFront, you set up cache behaviors that trigger the functions to run. The following snippet from the SAM template shows the configuration of the CloudFront distribution (for brevity, not all lines displayed):

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Condition: CreateCloudFrontDistribution
    Properties:
      DistributionConfig:
        CacheBehaviors:
          - PathPattern: !Ref RedirectPathSignIn
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt ParseAuthHandlerCodeUpdate.FunctionArn
            ...
          - PathPattern: !Ref RedirectPathAuthRefresh
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt RefreshAuthHandlerCodeUpdate.FunctionArn
            ...
          - PathPattern: !Ref SignOutUrl
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt SignOutHandlerCodeUpdate.FunctionArn
            ...
        DefaultCacheBehavior:
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !GetAtt CheckAuthHandlerCodeUpdate.FunctionArn
            - EventType: origin-response
              LambdaFunctionARN: !GetAtt HttpHeadersHandlerCodeUpdate.FunctionArn
        ...

The following are included in the configuration:

  • The RedirectPathSignIn (“/parseauth”) has a specific cache behavior that points to the ParseAuth Lambda@Edge function to handle the redirect from the user pool’s hosted UI after the user signs in. See the steps in Part 2 of the overview.
  • The RedirectPathAuthRefresh (“/refreshauth”) has a specific cache behavior that points to the RefreshAuth Lambda@Edge function that refreshes the ID and Access tokens when needed, using the Refresh token. (For brevity, this wasn’t included in the step-by-step earlier. For more information, see the code on GitHub.)
  • The SignOutUrl (“/signout”) has a specific cache behavior that points to the SignOut Lambda@Edge function, which signs the user out by expiring the tokens and signing out from the user pool. (For brevity, this wasn’t included in the step-by-step earlier. For more information, see the code on GitHub.)
  • There is a default cache behavior for all other URL paths, which runs the CheckAuth Lambda@Edge function (as shown in Parts 1 and 3 above). Another Lambda@Edge function, HTTPHeaders, is configured to add security HTTP headers to all responses. (For brevity, we’ll skip explaining that here. For more information, see the code on GitHub and in this blog post.)

Note that in CloudFront, Lambda@Edge functions must be referred to by using a specific version. One way to do this with SAM is to set your function to AutoPublishAlias. However, in this solution, we instead refer to the output of a custom resource. We’ll explain more about this later in the blog post.

Accessing and setting cookies in Lambda@Edge

Cookies are just HTTP headers, so they can be easily accessed in Lambda@Edge. Likewise, you can easily set a cookie in the HTTP response.

The following snippet shows a CloudFront event, as it is provided to a Lambda@Edge function. This example shows several JWTs and a nonce, included in the Cookie header. (The JWTs that are stored conform to the AWS Amplify naming scheme.)

{
    "Records": [
        {
            "cf": {
                "config": {
                    "distributionDomainName": "d1pvn3dps6qu4z.cloudfront.net",
                    "distributionId": "E9T5ZPHRAFNSP",
                    "eventType": "viewer-request",
                    "requestId": "vIxNrrBtkg3HYefeWstZrf3zB5D2NG1kByk1m4zuUJlvj4k6BiSyKg=="
                },
                "request": {
                    "clientIp": "203.0.113.123",
                    "headers": {
                        "cookie": [
                            {
                                "key": "Cookie",
                                "value": "CognitoIdentityServiceProvider.6rhseb1dsn8kqeqmq9one86m2r.00cf5085-94ae-400f-9c7e-8d8ef69db0bf.idToken=...; CognitoIdentityServiceProvider.6rhseb1dsn8kqeqmq9one86m2r.00cf5085-94ae-400f-9c7e-8d8ef69db0bf.accessToken=...; CognitoIdentityServiceProvider.6rhseb1dsn8kqeqmq9one86m2r.00cf5085-94ae-400f-9c7e-8d8ef69db0bf.refreshToken=...; spa-auth-edge-nonce=..."
                            }
                        ],
                        "host": [
                            {
                                "key": "Host",
                                "value": "d1pvn3dps6qu4z.cloudfront.net"
                            }
                        ],
                        "user-agent": [
                            {
                                "key": "User-Agent",
                                "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:60.0) Gecko/20100101 Firefox/60.0"
                            }
                        ],
                        "accept": [
                            {
                                "key": "Accept",
                                "value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
                            }
                        ],
                        "accept-language": [
                            {
                                "key": "Accept-Language",
                                "value": "en-US,en;q=0.5"
                            }
                        ],
                        "accept-encoding": [
                            {
                                "key": "Accept-Encoding",
                                "value": "gzip, deflate, br"
                            }
                        ]
                    },
                    "method": "GET",
                    "querystring": "",
                    "uri": "/"
                }
            }
        }
    ]
}

The following code snippet shows how you can set a cookie in a Lambda@Edge function—in this case a nonce—while redirecting the user to the user pool’s hosted UI.

export const handler: CloudFrontRequestHandler = async (event) => {
    const response = {
        status: '302',
        statusDescription: 'Found',
        headers: {
            'location': [{
                key: 'location',
                value: 'https://user-pool-hosted-ui-url/oauth2/authorize?state=xxx&…'
            }],
            'set-cookie': [{
                key: 'set-cookie',
                value: 'nonce=123; Secure; HttpOnly'
            }]
        }
    }
    return response;
}

You can review all the code for the solution’s Lambda@Edge functions on GitHub.

Using Lambda@Edge functions

Lambda@Edge has some key differences compared to AWS Lambda. For example, Lambda@Edge functions don’t support environment variables, but JWT validation and Cognito redirects require input, such as the User Pool ID and Client ID. So one challenge with using a Lambda@Edge function for authentication solutions is determining how to provide that input to the function.

This blog post explains several ways to add configuration values in Lambda@Edge viewer request functions. In the solution described here, we use a custom resource with a file called configuration.json to add required configuration input to the function’s deployment package. We have to include the inputs dynamically by using a custom resource because the values depend on other resources in the CloudFormation stack. The custom resource adds the configuration file, and then publishes a new Lambda function version for CloudFront to use.

Another difference between Lambda@Edge and Lambda is that the total bundle size of viewer request functions for Lambda@Edge can’t be greater than 1 MB. NodeJS bundle sizes can easily grow beyond 1 MB if the dependency tree is large. In fact, one of the NodeJS functions that we created was too large, so we used Webpack to reduce the bundle size. Webpack is a tool that is traditionally used to create JavaScript bundles for websites and SPAs. It concatenates all of your JavaScript code, including its dependencies, in one big JavaScript file, including just the code that you actually use. To see how we used Webpack for Lambda@Edge, check out the file webpack.config.js in the source repository.

Authenticating with Cognito and cookies

This solution works seamlessly with the AWS Amplify Framework and the Amazon Cognito Auth SDK for JavaScript. All you need to do when you write a SPA with these frameworks is to configure cookie storage for authentication tokens. Then, after you deploy your SPA to your S3 bucket, your SPA will recognize the JWTs set by the Lambda@Edge functions.

This compatibility works because the Lambda@Edge functions in our solution use the same cookie naming scheme that Amplify and the Cognito Auth SDK do. When you use the same naming scheme, the frameworks recognize the cookies with JWTs as if they had set the cookies themselves.

Even SPAs that don’t contain authentication logic—in fact, any assets that you store in your S3 bucket—are now protected by Cognito authentication. Do note, though, that the cookies are now sent along with each HTTP request to the domain. That’s not a concern with SPAs, because they aren’t downloaded frequently and they include a limited number of files, which browsers also cache. But if you store and serve other content with your S3 bucket, be aware of the extra overhead.

Choosing and configuring cookie settings

You can configure the cookie settings for this solution by changing the parameters when you deploy the CloudFormation stack. You can change the following options (shown with default values):

"cookieSettings": {
    "idToken": "Secure; SameSite=Lax",
    "accessToken": "Secure; SameSite=Lax",
    "refreshToken": "Secure; SameSite=Lax",
    "nonce": "Secure; HttpOnly; Max-Age=300; SameSite=Lax"
}

Cookies that hold JWTs are set to Secure but don’t include HttpOnly. By leaving off the HttpOnly setting, you enable JavaScript code—like Amplify or Cognito Auth SDK—to access the cookies.

Consider changing the settings as follows if it makes sense for your scenario:

  • Set the refreshToken cookie to HttpOnly. This is more secure, because the refresh token is valid for a significant time and so it poses more of a risk if it’s stolen by any malicious JavaScript that runs in your SPA. However, with the HttpOnly setting, Amplify and the Cognito Auth SDK can’t access the cookie in the SPA. Instead, you must refresh JWTs in Lambda@Edge, where you can access HttpOnly cookies. This is implemented in the sample solution’s RefreshAuth Lambda@Edge function, that we mentioned earlier. (Note that the default settings don’t include Max-Age or Expires for the refresh token cookie, making it a session cookie, but browsers often persist these anyway, as a convenience for users.)
  • Set all of the cookies to HttpOnly. If you don’t need JavaScript access to the cookies in your SPA, you can change the configuration to set the cookies to include HttpOnly. For example, in our cookie setting code sample, the nonce cookie is configured as HttpOnly because it should only be accessed by the Lambda@Edge functions that we configured in CloudFront. We also specified a short Max-Age setting for this cookie because the cookie only has to be available while a user is signing in.

Validating JWTs

The code sample in this section shows how our solution validates JWTs. The code requires, as input: the token itself, the JWKS URL, and the standard JWT attributes, issuer and audience. Fortunately, we can use several existing open source libraries, jsonwebtoken and jwks-rsa, to do a lot of the work.

The following is the code sample for validating a JWT. This code is called from the “CheckAuth” Lambda@Edge function handler.

import { decode, verify } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

// jwks client cached at this scope so it can be reused across Lambda invocations
let jwksRsa: jwksClient.JwksClient;

async function getSigningKey(jwksUri: string, kid: string) {
    // Retrieves the public key that corresponds to the private key with which the token was signed

    if (!jwksRsa) {
        jwksRsa = jwksClient({ cache: true, rateLimit: true, jwksUri });
    }
    return new Promise<string>((resolve, reject) =>
        jwksRsa.getSigningKey(
            kid,
            (err, jwk) => err ? reject(err) : resolve(jwk.publicKey || jwk.rsaPublicKey))
    );
}

export async function validate(jwtToken: string, jwksUri: string, issuer: string, audience: string) {

    const decodedToken = decode(jwtToken, { complete: true }) as { [key: string]: any };
    if (!decodedToken) {
        throw new Error('Cannot parse JWT token');
    }

    // The JWT contains a "kid" claim, key id, that tells which key was used to sign the token
    const kid = decodedToken['header']['kid'];
    const jwk = await getSigningKey(jwksUri, kid);

    // Verify the JWT
    // This either rejects (JWT not valid), or resolves (JWT valid)
    const verificationOptions = {
        audience,
        issuer,
    };
    return new Promise((resolve, reject) => verify(
        jwtToken,
        jwk,
        verificationOptions,
        (err) => err ? reject(err) : resolve()));
}

Reusing the solution in your own CloudFormation templates

If you like, you can deploy this solution by including it in your own CloudFormation template instead of deploying the solution through the Serverless Application Repository.

The following is an example of a CloudFormation template that reuses the application and adds a few users to the user pool:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  Example stack that shows how to reuse the serverless application and include your own resources

Resources:
  MyLambdaEdgeProtectedSpaSetup:
    Type: AWS::Serverless::Application
    Properties:
      Location:
        ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-lambda-edge-cognito-auth
        SemanticVersion: 1.0.0
  AlanTuring:
    Type: AWS::Cognito::UserPoolUser
    Properties:
      Username: alan.turing@example.com
      UserPoolId: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.UserPoolId
  EdsgerDijkstra:
    Type: AWS::Cognito::UserPoolUser
    Properties:
      Username: edgser.dijkstra@example.com
      UserPoolId: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.UserPoolId

Outputs:
  MySpaS3Bucket:
    Description: The S3 Bucket into which my SPA will be uploaded
    Value: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.S3Bucket

Summary

In this blog post, we’ve provided an overview for a solution to add simple, “drop-in” Cognito authentication to your SPA, which can help prevent unauthenticated users from downloading source code. We’ve discussed some highlights of the solution, but for more details, take a look at all of the related sources in GitHub.

You can easily deploy the solution through the Serverless Application Repository, or you can include parts of it in your own SAM templates as reusable artifacts. Another option is to clone the GitHub source repository and reuse any code or templates that you like.

Lambda@Edge adds a lot of versatility to CloudFront, and it’s worth becoming familiar with. Read more about Lambda@Edge in the CloudFront Developer Guide, and have fun coding!

As always, we’d love to hear from you. You can comment below or reach out to us on the Amazon CloudFront Forum. Please post your questions, issues or improvements concerning the code on GitHub.

Recognition

Naturally, this is not the first endeavor to utilize Lambda@Edge for authentication! Among others, the following resources were an inspiration to this blog post:

Additional resources

 

Otto Kruse is a professional services consultant based in the Netherlands focused on app development practices. AWS Professional Services helps customers build solutions on AWS.