close icon
AWS

Securing AWS HTTP APIs with JWT Authorizers

Learn about securing HTTP APIs built on AWS API Gateway using JWT authorization

Last Updated On: October 07, 2021

TL;DR: HTTP APIs — a new solution in AWS for building low-cost APIs — support JSON Web Token (JWT)-based authorization, and they integrate with external identity providers such as Auth0 for easy, industry-standard authorization practices. This tutorial will walk you through building an HTTP API using Amazon API Gateway and integrating it with Auth0 to restrict write access to authorized users.


Introduction

Let’s start with an obvious statement: building a modern API isn’t exactly a straightforward process. The list of requirements seems to grow: it should be performant, easily scalable, highly available, secure — and that’s before you get to writing your actual business logic!

Thankfully, there’s a constantly growing ecosystem of tools offering solutions for building, securing, and deploying APIs. The AWS API Gateway service provides several solutions for this exact problem, and their most recent offering, HTTP APIs, is a simple, low-cost, low-latency solution for API configuration and deployment.

Now, if you’re building an API, you’ll likely need to restrict endpoint access to at least some of your endpoints eventually. Identity-as-a-Service (IaaS) solutions, like Auth0, can easily handle authentication and authorization for a variety of applications, but how can they be integrated with services like API Gateway? That answer comes in the form of a new HTTP API feature supporting industry-standard authorization: JWT Authorizers.

JWT Authorizers at a Glance

Authorizers, as defined in API Gateway, are services that allow or restrict API access to clients based on several possible criteria such as authenticated users, permissions, IP addresses, and so on. JWT Authorizers are a new type of Authorizer which, as the name suggests, use JSON Web Tokens (JWTs) to provide access control to your API endpoints. JWT Authorizers build on the industry standards of the OpenID Connect (OIDC) spec and the OAuth 2.0 protocol.

Once configured, requests to your API will require an access token that the Authorizer will validate. The access token must adhere to the JWT format, and the Authorizer validates a number of the standard reserved claims following the JWT spec. The API Gateway Developer Guide contains more information about the validation process for JWT Authorizers if you’re curious.

JWT Authorizers support any identity provider — a service providing user identity storage and authentication — that can issue access tokens that follow OIDC and OAuth 2.0 standards, such as Auth0. JWT Authorizers are only supported by HTTP APIs at this time, making this a central benefit in choosing HTTP APIs over API Gateway’s other offerings. If you already rely on an existing identity provider for authentication and authorization that provides access tokens in the JWT format, it’s a simple process to configure a JWT Authorizer in your new API to leverage that provider’s solution.

And that, reader, is what you’ll be doing in this tutorial!

What Will You Build?

You’re going to build an API that represents a wish list of items, similar to Amazon’s Wish List functionality. Anyone will be able to read the list, but only authorized users will be able to add items to the list.

On a technical level, the implementation requires a few different moving parts:

  • An AWS Lambda function that handles the business logic of the wish list

  • A DynamoDB table that stores the wish list items

  • An HTTP API using API Gateway to handle requests and route them to the Lambda function

  • A JWT Authorizer configured to use Auth0 as the access token issuer to restrict write access to the wish list API to authorized users

One note for clarification: other solutions in API Gateway such as REST APIs can implement and use Lambda functions as custom authorizers — these are sometimes called lambda authorizers or authorizer functions. However, that’s not what you’re building here. Your Lambda simply handles the business logic, and you’ll configure and manage the JWT Authorizer through configuration in the AWS Console.

Here’s a diagram of how the various parts interact with each other:

Architecture Diagram of AWS HTTP APIs using JWT Authorizers

The request is handled as follows:

  1. An HTTP request is made to the API, which includes a JWT-formatted access token provided by a token issuer (e.g., Auth0.)
  2. The request is received by API Gateway, which passes the token to the JWT Authorizer for validation.
  3. The JWT Authorizer sends a request to the JSON Web Key Set (JWKS) endpoint to retrieve the authorization server’s public key used to verify the JWT.
  4. The Authorization server returns the public key to the JWT Authorizer.
  5. JWT Authorizer validates the access token, confirming with API Gateway that the request can continue.
  6. API Gateway runs the Lambda implementing the business logic of the API.
  7. The Lambda calls DynamoDB to read or write records, depending on the request
  8. If needed, data is returned from DynamoDB to the Lambda function.
  9. The Lamda finishes executing and returns a JSON object representing the HTTP response to API Gateway.
  10. API Gateway returns an HTTP response to the requesting application.

Prerequisites

Try out the most powerful authentication platform for free.Get started →

Create an HTTP API on AWS

You’re going to start by building your wish list API. The requirements are pretty straightforward: a GET request to the API returns all items on the wish list, and a POST request with the correct payload will add a new wish list item. For this step, anyone will be able to make both GET and POST requests.

As mentioned above, you’ll be using three AWS services:

  • DynamoDB: a NoSQL database to store our wish list items.

  • Lambda: the backend business logic that powers the API, implemented as a serverless function in Node.

  • API Gateway: the service to create the public API endpoint itself and wire requests to the Lambda.

Create DynamoDB Table

Your datastore is going to be pretty straightforward — you’ll just need a single table that stores blobs of data representing wish list items.

To get started, navigate to the AWS DynamoDB dashboard and click the “Create Table” button.

Name the new table “WishList” and use “id” for the name of the Primary Key. Leave the rest of the form as-is; just scroll down and click Create.

Settings for the DynamoDB table

And with that, your table is created, and your Dynamo configuration is complete!

Create AWS Lambda

With the database in place, it’s time to create the core business logic of your API. You’ll be implementing the logic using Node and AWS’ Lambda service, so head to the Lambda dashboard and click the “Create function” button in the table’s top right corner.

Configuring the Lambda

The Lambda service provides a lot of helpful blueprints and samples, but for this tutorial, you’ll be creating your Lambda’s functionality from the ground up. As a result, leave “Author from scratch” selected at the top of the form.

Next, give your function the name “wish-list-service” and select “Node 12.x” as the runtime.

Lambda Settings part 1

In the Permissions section, open the “Choose or create an execution role” accordion. Select “Create a new role from AWS policy templates” in the radio group. Name the role “wish-list-service-role” and, in the Policy templates dropdown, select “Simple microservice permissions.”

Settings for the Lambda

This policy template is a handy way to create the AWS IAM role that allows your Lambda to access the DynamoDB table you just created. If you’re curious about what specific permissions you’re granting by using this IAM policy template, you can go to the AWS IAM Roles Dashboard and click “wish-list-service-role” to access the full policy document.

Click “Create Function” in the bottom right corner of the page, and once the creation is complete, the details page for your new Lambda will be loaded.

Now you’re at the fun part: writing the code for your API!

Code Scaffolding

Start by replacing the existing code with the following function:

exports.handler = async (event, context) => {

};

Here’s a quick breakdown of the handler function:

  • You’re using the async keyword here because the Lambda implementation will eventually call DynamoDB asynchronously using the await keyword. The default handler includes a third parameter, callback, which you would typically need to call when the handler execution completes. However, since you’re using async, that’s not necessary.

  • The event argument is a core Lambda concept — it’s an object representing the data for the Lambda to process. In your Lambda’s case, event represents the HTTP request that your API receives. The API Gateway Documentation on Lambda Integrations contains a full schema of the event payload.

  • The context argument contains useful information about the execution environment; we’ll be using it for its awsRequestId property in a moment.

With the empty handler in place, the next step is to instantiate the object that allows you to access DynamoDB for your database reads and writes. You’ll use the AWS.DynamoDB.DocumentClient() constructor for this:

// NEW CODE
const AWS = require("aws-sdk");
const ddb = new AWS.DynamoDB.DocumentClient();

const TABLE_NAME = "WishList";

// Existing code
exports.handler = async (event, context) => {

}

Onward to the handler function body! Since the handler processes both GET and POST requests, a simple switch statement based on the HTTP method should do the trick:

// ...

exports.handler = async (event, context) => {
  switch(event.requestContext.http.method) {
    case "GET":
      break;
    case "POST":
      break;
    default:
      break;
  }
}

GET Handler

The GET case is simple enough: when a request is received, you’ll retrieve all records from the WishList table and return them:

// ...
switch(event.requestContext.http.method) {
  case "GET":
    // NEW CODE
    const data = await ddb.scan({ TableName: TABLE_NAME }).promise();

    return {
      statusCode: 200,
      body: JSON.stringify(data),
      headers: {
        "Content-Type": "application/json",
      }
    };
  case "POST":
    // ...

Let’s dig into the above implementation a bit more:

  • ddb.scan() will retrieve all records found in a DynamoDB table. The TableName key in the function parameter specifies which table to use.

  • The object that the Lambda returns must adhere to a specific format for API Gateway to transform it into an API response. Specifically, it must be an object with statusCode and body fields — headers is an optional field.

POST Handler

You don’t need anything too complicated for handling POST requests: the handler needs to grab some parameters from the request body and pass them into the database. The API will support three parameters:

  • name: the name of the item on your wish list

  • description: details about the item

  • url: a link to where the item can be purchased

You’ll also need an ID for the database’s primary key field for each object the API creates. For simplicity, you can use the guaranteed-to-be-unique awsRequestId property from the handler’s context object.

Finally, after the item is added to the database, you can return the ID in the body of the successful response.

Here’s what the code looks like:

// ...
switch(event.requestContext.http.method) {
  // ...
  case "POST":
    const {name, url, description } = JSON.parse(event.body);

    await ddb.put({
      TableName: TABLE_NAME,
      Item: {
        id: context.awsRequestId,
        name,
        url,
        description
      }
    }).promise();

    return {
      statusCode: 201,
      body: JSON.stringify({
        id: context.awsRequestId,
      }),
      headers: {
        "Content-Type": "application/json",
      }
    };
  default:
    // ...

Your Lambda is now complete!

Here’s a full snapshot of the implementation code:

const AWS = require("aws-sdk");
const ddb = new AWS.DynamoDB.DocumentClient();

const TABLE_NAME = "WishList";

exports.handler = async (event, context) => {
  switch(event.requestContext.http.method) {
    case "GET":
      const data = await ddb.scan({ TableName: TABLE_NAME }).promise();

      return {
        statusCode: 200,
        body: JSON.stringify(data),
        headers: {
          "Content-Type": "application/json",
        }
      };
    case "POST":
      const {name, url, description } = JSON.parse(event.body);

      await ddb.put({
        TableName: TABLE_NAME,
        Item: {
          id: context.awsRequestId,
          name,
          url,
          description
        }
      }).promise();

      return {
        statusCode: 201,
        body: JSON.stringify({
          id: context.awsRequestId,
        }),
        headers: {
          "Content-Type": "application/json",
        }
      };
    default:
      break;
  }
};

Dev tip: if you’re copying the above code into your Lambda, you can automatically format code in the Lambda editor with the following steps:

  • Click the cog in the upper right corner of the editor to open the Preferences panel
  • Under “Project Settings,” click ”Code Formatters”
  • Toggle the ”Format Code on Save” setting under the JSBeautify section

Make sure to save your Lambda using the orange “Save” button in the top right corner.

The Lambda is now up and running, and you’re ready for the next step: creating the API.

Create an HTTP API

There are a few different ways to create an HTTP API, but the easiest method is within the Lambda’s details page itself. This way, AWS will automatically configure the API’s permission settings to execute the Lambda when a request is received.

To create the API, you’ll use a section at the top of the Details page called Function Designer — this is a visual representation of the Lambda, plus any other AWS services that connect to it.

Lambda Designer

These connections take the form of Triggers — various AWS services that can invoke your Lambda function — and Destinations — services to which the Lambda can route its return values. You can find more information about these connections in the Function Configuration docs on AWS.

In the Function Designer, click the “Add trigger” button on the diagram’s left side to start creating your API. Select “API Gateway” in the form’s dropdown, then “Create a new API” from the next dropdown that appears. The form should select the HTTP API option by default.

In the Security dropdown that appears, select the “Open” option. You could technically create the JWT Authorizer by selecting “Create JWT Authorizer” in the dropdown, but doing would require an access token for any call made to your API. Since the API you’re building allows unauthorized GET requests, it makes more sense to add the JWT Authorizer to just POST requests in the API gateway dashboard, which you’ll do later in this tutorial.

Adding an HTTP API trigger to your Lambda

Once you click the “Add” button, you’ll be taken back to the Lambda’s details page. In the Designer section, the left branch should now contain a linked child called API Gateway. Below the Function Designer, a section called API Gateway should be visible; this area will include details about your newly-created HTTP API.

Note: if the API Gateway section below Function Designer isn’t visible, click the API Gateway box in the Designer to select it; the API Gateway section should then appear.

The newly created API in the Function Designer

By creating the API through the Function Designer, the API endpoint should be automatically connected to the Lambda, meaning that the first version of the wish list API should be complete!

To test out your API, grab its URL from the API Gateway section below the Designer diagram and open up a new terminal window.

First, make a POST request to your endpoint using cURL to create a new wish list item:

$ curl -H "Content-Type: application/json" \
  -X POST \
  -i \
  -d '{"name": "Test Item", "description": "Test Description", "url": "https://www.amazon.com"}' \
  https://[SUBDOMAIN].amazonaws.com/default/wish-list-service

The -i flag is included to provide more information about the response from the API.

You should get back a 201 response with a JSON payload containing an id key.

Next, make a GET request to ensure the item creation was successful:

$ curl -i https://[DOMAIN].amazonaws.com/default/wish-list-service

The returning JSON payload should contain a key called Items, the value of which is an array of wish list items. Your new item will appear in that array.

Congrats! You’ve successfully built a Lambda-powered HTTP API for running a simple wish list.

Add a JWT Authorizer to Your API

In this section, you’ll be tackling the heart of the problem: adding a JWT Authorizer to your API so that only authorized users can add items to the list.

Create a New Auth0 API

To configure the JWT Authorizer, you’ll first need to set up a new Auth0 API to act as the identity provider.

After you’ve signed in (or signed up), head to your Auth0 Dashboard and click “APIs” in the left-hand menu. Click the “Create API” button and fill out the form with the following values:

an example of the Auth0 API settings

Click the “Create” button, and the console will take you to the definition page for your new API.

Configure API Gateway Settings for POST Requests

Head back to the API Gateway console in AWS and click “wish-list-service-API” to open up the API’s details page.

By default, HTTP APIs allow any type of request to the wish-list-service endpoint, so that’ll be the first thing to change. To do this, navigate to the “Routes” section from the left-hand menu.

API Gateway Routes dashboard

Click “ANY” under the “/wish-list-service” route in the left column, then click “Edit” in the details card’s header on the right. In the dropdown, change “ANY” to “GET” and click save.

Changing the route to GET only

Once this change is applied, you can test it by making a POST request as before:

$ curl -H "Content-Type: application/json" \
  -X POST \
  -i \
  -d '{"name": "Test Item", "description": "Test Description", "url": "https://www.amazon.com"}' \
  https://[SUBDOMAIN].amazonaws.com/default/wish-list-service

You should receive a 500 response with the following payload:

{"message":"Internal Server Error"}

In your case, this error means that your configuration works! Your API temporarily only supports GET requests.

Next, you’ll want to create a different configuration for POST requests to the same endpoint. From the Routes dashboard, click “Create” in the left column header to create a new route.

Adding a POST method to the route

Select “POST” from the methods dropdown and set the URL to /wish-list-service, then click the “Create” button.

Adding a POST method to the route

You’ll be redirected once again to the Routes page, and your POST method should now appear in the left column under the “/wish-list-service” route. Click “POST” to open up the details card.

You’ve now created your route, but you’ll still need to configure it to call your Lambda. To do so, click “Attach Integration” at the bottom of the details card; this will route you to the Integrations section of your API dashboard.

POST method details

Select “wish-list-service” from the dropdown of existing integrations and click the “Attach Integration” button to connect your API to your Lambda.

Attaching Lambda integration to your API

If all has gone according to plan, POST requests should now work again. Run the following cURL command to verify that everything’s working (note that you may need to wait 10-20 seconds for the API to redeploy):

$ curl -H "Content-Type: application/json" \
  -X POST \
  -i \
  -d '{"name": "Another Test Item", "description": "Another Description", "url": "https://www.amazon.com"}' \
  https://[SUBDOMAIN].amazonaws.com/default/wish-list-service

You should receive a 201 in response — POSTs are back!

Configure the JWT Authorizer

You’re in the home stretch — the last step is to configure the API to restrict POST requests to authorized users. Head to the “Authorization” section via the left-hand menu. In the left column, click “POST” under the “/wish-list-service” route, then click the “Create and attach an authorizer” button.

Authorization section

Fill out the form with the following details:

  • Name: auth0

  • Identity source: $request.header.Authorization

  • Issuer URL: Your Auth0 tenant URL (e.g., https://[YOUR-TENANT-NAME].auth0.com/)

  • Audience: https://auth0-jwt-authorizer

JWT Authorizer settings

If any of the fields are unfamiliar to you, here’s a quick breakdown:

  • The Identity source indicates where the Authorizer can find the access token in the request. In this case, it’ll be in the Authorization header.

  • The Issuer URL is the base domain of the identity provider from which the JWT originates. The Authorizer uses this value to check one of the claims inside the JWT access token. Specifically, it must match the value provided in the iss claim. The Issuer URL is a publicly available property found in a metadata endpoint for your identity provider (e.g. https://[YOUR-TENANT-NAME].auth0.com/.well-known/openid-configuration), as per the OpenID Connect spec.

  • The Audience field is used to indicate that your HTTP API is a valid recipient of the JWT. Specifically, the Authorizer verifies that the aud claim inside the JWT access token contains the unique identifier provided in the “Audience” form field. Specifically, the value being used here is your Auth0 API’s unique identifier from the “Create new Auth0 API” step above.

Note: if you don’t remember your Auth0 API identifier or Tenant URL, you can find both values on your Auth0 dashboard.

To find the API Identifier, go to the APIs page in the lefthand menu. The identifier for your API is labeled as “API Audience” on the landing page.

Auth0 API Settings

For the Tenant URL, go to “Applications” in the lefthand menu. Each API has a test application created by default, so find the application called “AWS JWT Authorizer (Test Application)” and click on the application name. In the settings page, locate the “Domain” field and click the “Copy to clipboard” button in the far right of the field.

Tenant URL Settings

All that’s left now is to click “Create and attach” in the Authorizer form! You should now see a green “JWT” pill next to the POST method on your endpoint in the left column of the “Authorization” page.

Take a breath and pat yourself on the back — you’ve successfully configured your JWT Authorizer. The only thing left to do is test it out.

Test It Out!

First, you should ensure the Authorizer is working properly. In your terminal window, make another POST request to the API:

curl -H "Content-Type: application/json" \
  -X POST \
  -d '{"name": "Failing Test Item", "description": "Failing Test Description", "url": "https://www.amazon.com"}' \
  -i \
  https://[SUBDOMAIN].amazonaws.com/default/wish-list-service

You should receive a 401 response, the body of which includes a message key with a value of Unauthorized. That’s a good sign — your API no longer allows unauthorized creation of wish list items!

Your GET request should still work correctly for unauthorized requests.

$ curl -i https://[DOMAIN].amazonaws.com/default/wish-list-service

You should see a 200 response with your wish list items in the body.

Make a Request as an Authorized User

The moment of truth is here — it’s time to request your API with an access token.

In the real world, a user would first need to authenticate against your identity provider — Auth0 in this demo’s case — which would provide an access token. For this tutorial, however, you can grab a test access token from your Auth0 API configuration.

Head to your Auth0 API dashboard and navigate to the details page of the custom API you created back at the beginning of this tutorial. Go to the “Test” section and locate the ”Response” section. Click the “copy token” button in the top right of the code snippet to copy the access_token.

The access_token property from Auth0 test section

We’ll be adding this to our cURL POST request as follows:

curl -H "Content-Type: application/json" \
  -H "Authorization: Bearer [ACCESS_TOKEN]" \
  -X POST \
  -d '{"name": "Test Item 3", "description": "Test Description 3", "url": "https://www.amazon.com"}' \
  -i \
  https://[SUBDOMAIN].amazonaws.com/default/wish-list-service

That request should return a 201 status code, meaning that you added a wish list item because your access token was valid. In other words, your API is successfully secure! If you want to verify that the item was added to the list, fire off one more GET request to the API to see the results.

Summary

HTTP APIs provide a simple and powerful way to create API endpoints, as you saw by building a functioning endpoint powered by a Lambda. With JWT Authorizers, you can restrict access to your endpoints using the industry standards of OIDC and OAuth 2.0, and combining your API with an identity provider such as Auth0 means that you don’t need to worry about rolling your own authentication and authorization solutions. Your primary focus can stay on what’s important: the business logic of your API.

From this tutorial, there are several next steps you could take. For example, you could expand the functional capabilities of your API in the Lambda itself, or you could refine access by adding authorization scopes within Auth0 and restricting your API endpoints or methods based on more granular permissions.

Happy building!

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon