Secure Your Static Website with AWS CloudFront and Lambda

One of the possible applications of Lambda@Edge is pre-processing and post-processing of the requests that flow through CloudFront. Therefore Lambda@Edge can be used to authorize the user to access a resource behind CloudFront. CloudFront is often used to serve static content such as Single Page Applications stored in S3.

This article covers an approach on how to protect sensitive parts of your Single Page Application written using ReactJS by leveraging both frontend and backend Authorization, AWS Cognito, Lambda@Edge and CloudFront.

Photo by Aubrey Odom on Unsplash

Even though you should not store any sensitive data in your frontend, you might want to hide the front-end logic of your App (things like field names, endpoints and data structures) from unauthorized users to prevent, for example, reverse engineering.

TL;DR

You can protect selected parts of your ReactJS App by splitting code in chunks with webpack, serving them through CloudFront, and authorizing with Lambda@Edge and AWS Cognito. Code is here.

High-Level Diagram

Creating a Single Page Application

First, we need to create a simple ReactJS App. For this, we can use create-react-app .

# create-react-app aws-react-jwt-auth

Within your app, create two separate components— a public component that will hold information available to the public without authorization and a protected component, that will be loaded and available only for authorized users.

Public component (Public.js):

Protected component (Protected.js), that contains some logic that you don’t wish to expose to the public:

App.js will need to facilitate navigation between public and protected components and is as simple as possible for needs of this post:

If you now run your application locally, you will see something like this:

Navigation between public and protected components

If you check network logs, you will see that all the chunks of the App are loaded at the same time on App startup.

In order to avoid loading of a protected component at the startup time, create an Async wrapper. To read more about this approach check out this article:

To enable asynchronous loading for a specific component, modify App.js and wrap protected component with newly introduced asyncComponent function:

import asyncComponent from "./AsyncComponent";
import {withAuthenticator} from 'aws-amplify-react';

const AsyncProtected = asyncComponent(() => import("./Protected"));

class App extends Component {
render() {
return (
...
<Route path="/protected" component={AsyncProtected}/>
...
);
}
}

While the protected component is now loaded asynchronously we don’t control where the respective code component is. To change that and place sensitive code separately, mark protected components with webpack magic comments in App.js:

const AsyncProtected = asyncComponent(() => import(/* webpackChunkName: "protected/a" */ "./Protected"));

If you check logs now, you will see the magic 🧙‍♂️:

Loading protected components separately

Quick and easy way to enable user authentication through AWS Cognito is to use AWS Amplify library that has an extension for ReactJS Apps.

First, go to AWS Console and create a new User Pool in AWS Cognito with default settings. Under newly created User Pool add a new App Client and don’t forget to de-select “Generate Client Secret” as there are no secrets in javascript. 😉

The configuration will look something like this:

Cognito configuration

Next, add AWS Amplify libraries to your single page app:

npm i aws-amplify aws-amplify-react

Configure Auth module in your App, for example in index.js. The configuration includes references to your User Pool and Client configured above as well as the storage configuration for JWT tokens:

Wrap protected component routing in withAuthenticator() before it gets loaded in App.js to enable authentication:

<Route path="/protected" component={withAuthenticator(AsyncProtected)}/>

Here is complete App.js.

Now, register a user in your Cognito User Pool and try out the authentication (I added “Sign Out” button for ease testing):

Adding authentication with AWS Cognito and Amplify to you App

While your App is now protecting the contents of the second component, it is done on the best effort. And still, everybody who opens network logs and checks what is loaded can see javascript code of the protected component. Note: don’t store any sensitive data on the frontend. This post offers you a way to protect the logic of how you process your sensitive data from the backend, not the sensitive data itself.

Let’s now protect the code of the second component on the backend using Lambda@Edge.

Creating CloudFront Distribution

There are plenty of articles about deploying single page apps to S3 behind CloudFront. One that is my favorite:

Follow steps in the article to prepare your S3 and CloudFront distribution for the next step.

Creating Lambda@Edge function to Authorize access

Now, it is time to secure a JS chunk with protected logic of the App on the backend. For this, we will need to create a Lambda@Edge function that will validate authorization with JWT when the related chunk is requested.

First of all, create a new Lambda in N. Virginia — where all Lambda@Edge functions live.

In the code, filter out all the requests that do not go to /static/js/protected path and ignore them as they don’t require authorization:

exports.handler = (event, context, callback) => {
const cfrequest = event.Records[0].cf.request;
if (!cfrequest.uri.startsWith("/static/js/protected")) {
callback(null, cfrequest);
return true;
}
...
}

All other requests will be subject to authorization using JWT that client is expected to send in the headers. Validation of JWT token consists of the following steps:

  1. Check if the JWT token is present and has a valid structure
  2. Ensure that JWT token was generated by the trusted User Pool
  3. Validate JWT signature

The full code is available in the GitHub repository.

Once your Lambda is ready, prepare it for deployment.

Update Trust Relationship of the role of your Lambda to include edgelambda.amazonaws.com in your AssumeRole statement:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": [
"edgelambda.amazonaws.com",
"lambda.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}

Now you can deploy you Lambda@Edge and attach it to CloudFront distribution. Open your Lambda and then go to Actions > Deploy to Lambda@Edge:

Pick the right CloudFront distribution in the deployment dialog

After deployment, wait for a couple of minutes for Lambda to propagate and then try accessing your protected JS chunk. You should see 401 error page, like below:

A protected chunk of JS is now secured on the backend

Now, everything is ready for final testing.

Let’s Test It

To validate the setup, go to CloudFront distribution URL, open protected component and login with you AWS Cognito credentials:

End-to-end test

👏

Final Notes and Considerations

  1. While this post offers you a way to protect some of the parts of your JS App by backend authorization, it doesn't secure the data that JS App loads. Secure your APIs separately
  2. Avoid storing any sensitive data in the JS App code.
  3. Lambda@Edge costs money and it is not part of Free-Tier.
  4. AWS Cognito gives you much more than just username/password login covered in this article. For example, you can add authenticators like Social Sign-In, SAML, MFA.
  5. Complete App and Lambda code is available on GitHub.

Hope you enjoyed this post. Clap and comment!

Engineering Lead @ Square, Co-Founder of Sygn — on a journey to create a frustration-free payment experience