Secure Your AWS Lambdas with TypeScript
Tags: typescript, javascript
Published: 2023-01-25 11:44:44

In the previous part of this series, we optimized our Lambda function. However, our API is open to the public — anyone with the URL can use it and get a response.

In this take, we are going to secure our API using a tool called Amazon Cognito. This will only allow authenticated users access to our endpoints.

Ready? Let’s go!

## What Is Cognito?

The AWS cloud has [Amazon Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html), an identity and access management tool. With this, you can create user accounts, manage their credentials, and generate a JWT.

## What Is a JWT?

Think of a JWT as an identity card. It identifies you as a real person and grants you basic access to a resource like an endpoint.

JWT stands for JSON Web Token and allows for two parties, like a client and server, to exchange claims. The JWT gets signed using a cryptographic algorithm to ensure that claims cannot be altered after the token is generated. A JWT is also self-contained, meaning it does not depend on database queries or a server session to grant authentication.

We can secure our API with a bearer JWT token. This way, only authenticated users with valid credentials can use our API.

Let’s see how we can set up Amazon Cognito and generate a JWT.

## Set Up Amazon Cognito

First, you need to create a user pool on the AWS cloud. Go to AWS and look for Cognito.

Follow these steps:

- Click **Create user pool**
- Click **Username** and **Allow users to sign in with a preferred username**
- Go to **Next**
- Disable MFA, so click **No MFA**
- Disable **Enable self-service account recovery**
- Go to **Next**
- Disable **Self-registration**
- Disable **Cognito-assisted verification and confirmation**
- Skip past **Required attributes** and **Custom attributes**
- Go to **Next**
- On **Email**, click **Send email with Cognito**
- Go to **Next**
- Under **User pool name**, type `pizza-api-cognito` and make note of this name
- On **Initial app client**, select **Other** for a custom app
- Under **App client name**, type `pizza-api-client`
- Click **Generate a client secret**
- Expand **Advanced app client settings** and select **ALLOW_USER_PASSWORD_AUTH** authentication flow
- Disable **Token revocation** and **User existence errors**
- Go to **Next**
- Click **Create user pool**

This may feel like a lot of steps to set up a user pool, but you’re really doing two things:

1. Allowing the username and password auth flow.
2. Creating a new app client.

MFA, self-service, and token revocation are outside the scope of this article, so we have these options disabled in Amazon Cognito.

Once these steps are complete, note the user pool ID, then click on your user pool.

## Set a Username and Password

Now, under **Users**, click on **Create user**. Set the **username** to `regular` and make a note of it. Go ahead and click on **Generate a password** so that we can set the password later.

Then, click on **Create user**.

Note that the confirmation status says **Force change password**. This is because the password is currently unconfirmed.

To set a new password, run this AWS CLI command:

```shell
> aws cognito-idp admin-set-user-password --user-pool-id USER_POOL_ID --username regular --password Passw0rd! --permanent
```

The password has the following requirements:

- A minimum length of 8 characters
- Contains at least 1 number
- Contains at least 1 special character
- Contains at least 1 lowercase character

Be sure to set a more secure password and use that instead of this very insecure password. Replace `USER_POOL_ID` with the user pool ID from earlier.

Go back to AWS and check that the password is now **Confirmed**. We now have a legit password to use in the credentials.

## Create a Client Secret Hash for AWS Cognito

Next, we’ll need a script that generates a secret hash before generating a JWT. If you do not provide a proper hash, you will see an [error](https://aws.amazon.com/premiumsupport/knowledge-center/cognito-unable-to-verify-secret-hash/) because Amazon Cognito cannot verify the secret hash.

Create a file — say, `generate-client-hash.js` — and write this algorithm:

```javascript
const crypto = require("crypto");

HMAC_ALGORITHM = "sha256";
HMAC_FORMAT = "hex";
HASH_ENCODING = "base64";

const arguments = process.argv;

const username = arguments[2];
const clientId = arguments[3];
const clientSecret = arguments[4];

const hmac = crypto.createHmac(HMAC_ALGORITHM, clientSecret);
const data = hmac.update(username + clientId);
const genHmac = data.digest(HMAC_FORMAT);
const buff = Buffer.from(genHmac, HMAC_FORMAT);
const hash = buff.toString(HASH_ENCODING);

console.log("secret hash: " + hash);
```

The algorithm simply creates a base64 encoded hash from the username, client ID, and client secret.

To nab the client ID and secret, go to AWS and then Amazon Cognito, click on your user pool, and click **App integration**. The `pizza-api-client` should be at the bottom of the page with the client ID. To reveal the client secret, go to your client, then click **Show client secret**.

To generate a secret hash, run this command:

```shell
> node generate-client-hash.js regular CLIENT_ID CLIENT_SECRET
```

Make a note of this secret hash before continuing to the next section.

<Banner goal="node_banner_from_blog" />

## Generate a JWT

Phew, with Amazon Cognito out of the way, this is where the real fun begins.

Simply gather all your necessary inputs: client ID, password, and secret hash, and then do this:

```shell
> aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH --client-id CLIENT_ID --auth-parameters USERNAME=regular,PASSWORD=PASSWORD,SECRET_HASH=SECRET_HASH
```

Note the `IdToken` in the result — this is the bearer JWT token you’ll use for authentication.

Let’s take a quick pause and inspect the token using [jwt.io](https://jwt.io/). Copy-paste the JWT into the **Debugger** to see what’s in the token.

```json
{
  "sub": "26412b5f-7752-4176-a3de-1922b4690097",
  "aud": "4nfibi3q3jivsdoo38save91ku",
  "event_id": "46580b57-dabc-47e3-a4be-6461ab6d1c40",
  "token_use": "id",
  "auth_time": 1665342105,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_1BfH4Jwkp",
  "cognito:username": "regular",
  "exp": 1665345705,
  "iat": 1665342105
}
```

Because the JWT is also signed by Amazon Cognito, it is impossible to brute force an attack on our endpoint with the bare claims. The beauty of this is that it is self-contained and has sufficient information in the claims to authenticate a real person.

For example:

- `sub` uniquely identifies our Amazon Cognito user
- `token_use` claims this is an ID token
- `exp` sets an absolute expiration.

So, even if somebody somehow gets a hold of this token, it will long have expired by the time this article is published. In Amazon Cognito, the ID token expires an hour after it is generated.

## Update the API Gateway in TypeScript

Finally, open up `api.ts` in the `pizza-api` code repo. We’ll need to make two changes: register an authorizer, and assign the authorizer to the endpoint.

Do this:

```typescript
api.registerAuthorizer("pizza-api-cognito", {
  providerARNs: ["USER_POOL_ARN"],
});

api.get("/", () => ["Welcome to AWS"], {
  cognitoAuthorizer: "pizza-api-cognito",
});
```

The `USER_POOL_ARN` is a unique identifier you can find on AWS. Simply click on the user pool, and copy-paste the ARN in **User pool overview**.

With these changes done, it’s time to run `npm run update` and let Claudia do the rest of the work.

To verify that changes have been made on the API gateway:

1. Go to AWS, and click **API Gateway**.
2. Open up `pizza-api` and inspect the **Authorizers**. There should be a `pizza-api-cognito` authorizer.
3. Check the API endpoints under **Resources**. The main `/` endpoint should have `COGNITO_USER_POOLS` assigned to the endpoint.

There is the option to manually do all this work on the API gateway, but the Claudia deploy tool automates all these steps for us.

## Test It Out!

Fire up CURL to verify that the endpoint is no longer open to the public:

```shell
curl -i -H "Accept: application/json" "https://API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/latest"
```

This should return a 401 Unauthorized.

Then, if you’re following along, nab the ID token from Cognito (since it has not quite been an hour) and put it in the authorization header:

```shell
curl -i -H "Accept: application/json" -H "Authorization: Bearer JWT_ID_TOKEN" "https://API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/latest"
```

This should return a 200 with a proper response.

## Authentication and Authorization with Amazon Cognito

In general, Amazon Cognito allows you to customize the auth flow via triggers and scopes. The Cognito user pool is standalone and can grant access based on who’s in the pool and what the claims are.

For more complex authorization use cases, it might be best to break users apart into separate pools. This allows for full control over who has more privileges within the system. One nicety of this technique is that Amazon Cognito handles both authentication and authorization, which means your Lambda function does not execute without proper access. This helps in lowering serverless costs.

Note that the API gateway is the one driving authorization because our Lambda function is merely an event instead of a reverse proxy. This technique unlocks the ability to block access without having to execute the Lambda function.

With what we have so far, basic access is granted to the `/` endpoint. If we wanted higher privileges to call, say, a POST `/pizzas` to make a pizza, then a separate user pool can handle this requirement.

## Final Thoughts on AWS Lambdas with TypeScript

In this series, we built a TypeScript Lambda, improved the dev experience, optimized it, and have now secured it. I hope you’ve found it useful.

The serverless cloud offers many opportunities for developers to iterate quickly and deliver value to happy customers. What is most exciting about this is that it follows the adages from the [Unix philosophy](http://xahlee.info/UnixResource_dir/writ/unix_phil.html) that “small is beautiful" and to "make each program do one thing well”.

Happy coding!

**P.S. If you liked this post, [subscribe to our JavaScript Sorcery list](https://blog.appsignal.com/javascript-sorcery) for a monthly deep dive into more magical JavaScript tips and tricks.**

**P.P.S. If you need an APM for your Node.js app, go and [check out the AppSignal APM for Node.js](https://www.appsignal.com/nodejs).**