Creating Verifiable JSON Web Tokens (JWTs) with AWS CDK
Tags: aws, cdk, jwt
Published: 2021-03-06 20:45:08

*Finally you can trust your grocery list*

JSON Web Tokens (JWTs, pronounced "jots") are pretty neat.  You can use JWTs to share information in a way that you can verify it wasn't altered.

Let's say the Martz family has a grocery list in JSON format (because that's normal... *RIGHT?!?*).  Whitney has put a bunch of healthy food on there for me to buy... but how can we tell Veronica didn't add a bunch of cookies and candy to the list?  If we store it as a JWT, Whitney can use her private key to sign the grocery list so that when I go to the store, I can check the list using Whitney's public key.  After I publish this article I bet everyone starts doing this.

![hipster](https://media.giphy.com/media/MFU1YMzKA7b8yQg0UN/giphy.gif "hipster")

This is just one contrived example... there are a LOT of applications for using JWTs.

So let's set up a quick stack in CDK that can do all of this.

[Here's the repo](https://github.com/martzcodes/blog-cdk-jwt)

This project was created using [projen](https://github.com/projen/projen).  It has 3 lambda functions, 1 S3 bucket, 1 RestAPI and [1 Secret in Secrets Manager](#secret-manager-costs).  All created using CDK.  It also has 100% unit test coverage 😁.

# Create the JSON Web Key Set (JWKS)

First we need to create the [JSON Web Key Set (JWKS)](https://tools.ietf.org/html/rfc7517) which are the private and public keys used to create the JWT.  We're going to store the private key in Secrets Manager and the public key in an S3 bucket and then serve that via an AWS Integration with the RestAPI so that it is publicly accessible.

A custom resource that is run whenever we bump a version will generate the JWKS and store them in the appropriate places.

## The Stack

The stack code for this is located here in [src/main.ts](https://github.com/martzcodes/blog-cdk-jwt/blob/main/src/main.ts#L15-L90)

We create the secret:

```typescript
const SECRET_ID = 'BlogCdkSecret';
const secret = new Secret(this, SECRET_ID);
```

Then create the PRIVATE S3 bucket and give permission for the API Gateway service to read it:

```typescript
const BUCKET_ID = 'BlogCdkJwksBucket';
const jwksBucket = new Bucket(this, BUCKET_ID, {
  accessControl: BucketAccessControl.BUCKET_OWNER_FULL_CONTROL,
  bucketName: `blog-cdk-jwks-bucket-${this.account}`,
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
const bucketRole = new Role(this, 'BlogCdkJwksBucketRole', {
  roleName: 'BlogCdkJwksBucketRole',
  assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
jwksBucket.grantRead(bucketRole);
```

And we create the lambda that will generate the actual JWKS:

```typescript
const jwksGenerator = new NodejsFunction(this, 'BlogCdkJwksGenerator', {
  ...lambdaProps,
  timeout: Duration.seconds(30),
  entry: join(__dirname, './jwks-generator.ts'),
});
secret.grantWrite(jwksGenerator);
jwksBucket.grantWrite(jwksGenerator);

const jwksGeneratorProvider = new Provider(this, 'BlogCdkJwksGenerateProvider', {
  onEventHandler: jwksGenerator,
});

new CustomResource(this, 'BlogCdkJwksGenerateResource', {
  serviceToken: jwksGeneratorProvider.serviceToken,
  properties: {
    // Bump to force an update
    Version: '2',
  },
});
```

We also give the Lambda write permission to both the Secret and the S3 Bucket.

## The Generator Lambda

[The lambda itself is actually REALLY boring.](https://github.com/martzcodes/blog-cdk-jwt/blob/main/src/jwks-generator.ts)

It creates the key in the key store

```typescript
const keyStore = JWK.createKeyStore();
const key = await keyStore.generate('RSA', 2048, {
  alg: 'RS256',
  use: 'sig',
});
```

And then stores the private one in the Secret:

```typescript
const secret = key.toJSON(true);
await sm
  .putSecretValue({
    SecretId: `${process.env.SECRET_ID}`,
    SecretString: JSON.stringify(secret),
  })
  .promise();
```

And the public one in S3

```typescript
const publicKey = key.toJSON();
await s3
  .putObject({
    Bucket: `${process.env.BUCKET_ID}`,
    Key: 'jwks.json',
    Body: JSON.stringify({ keys: [publicKey] }),
    ACL: 'bucket-owner-full-control',
  })
  .promise();
```

*Note: The JWKS should be stored as a LIST of keys, not just the key itself.  Meaning it should be stored as `{ keys: [key] }` not `{ ...key }`.  Some services like auth0 include the current key and the next key they'll use in that key list.*

## The API Integration

The API Integration is also pretty simple.

```typescript
const restApi = new RestApi(this, 'BlogCdkJwksApi');

const jwksIntegration = new AwsIntegration({
  service: 's3',
  integrationHttpMethod: 'GET',
  path: `${jwksBucket.bucketName}/jwks.json`,
  options: {
    credentialsRole: bucketRole,
    // integration responses are required!
    integrationResponses: [
      {
        'statusCode': '200',
        'method.response.header.Content-Type': 'integration.response.header.Content-Type',
        'method.response.header.Content-Disposition': 'integration.response.header.Content-Disposition',
      } as IntegrationResponse,
      { statusCode: '400' },
    ],
  },
});

restApi.root
  .addResource('.well-known')
  .addResource('jwks.json')
  .addMethod('GET', jwksIntegration, {
    methodResponses: [{ statusCode: '200' }],
  });
```

The `AwsIntegration` can have an endpoint point to an AWS Service (like S3) and specify the file it should load.  Our `jwks.json` file is located at the root of our S3 bucket, so we just need to have the `.well-known/jwks.json` endpoint point to it.

This integration uses the bucketRole we created earlier that gave the `apigateway.amazonaws.com` service permissions to read.  When using an `AwsIntegration` you MUST use `integrationResponses`.  If you don't even a good request will return a 500.  I ran into this issue when setting it up the first time and discovered it doing an API Test via the API Gateway Console.  The output for that looks like this:

```bash
Sat Mar 06 19:15:42 UTC 2021 : Execution failed due to configuration error: No match for output mapping and no default output mapping configured. Endpoint Response Status Code: 200
Sat Mar 06 19:15:42 UTC 2021 : Method completed with status: 500
```

If you deploy only those parts of the stack you'll get a shiny endpoint that hosts up the `jwks.json` file stored in S3 but accessible via the API.

![Screen Shot 2021-03-06 at 2.18.55 PM.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1615058344329/SnOTaxM1v.png)

But that's not super useful on its own if you can't actually create a JWT to use with it.  So let's do that!

# Creating a JWT

For creating the JWT we use the private key that was stored in secrets manager.  Normally this endpoint would be behind some sort of authentication but for the sake of this demo I skipped that.  It somewhat defeats the purpose if anyone can create a valid JWT, afterall.

All our shiny ["encoder-ring"](https://github.com/martzcodes/blog-cdk-jwt/blob/main/src/encoder-ring.ts) Lambda does is get the private key from secrets manager

```typescript
const secret = await sm.getSecretValue({ SecretId: `${process.env.SECRET_ID}` }).promise();
const jwks = JSON.parse(`${secret.SecretString}`);
const token = await signToken(tokenPayload, jwks);
```

And put whatever was in the post body into the payload

```typescript
export const signToken = async (tokenPayload: any, keyBuffer: Buffer) => {
  const key = await JWK.asKey(keyBuffer);
  const signOptions = { compact: true, fields: { typ: 'jwt' } };
  const currentTime = Math.floor(Date.now() / 1000);
  const payload = {
    exp: currentTime + 86400, // 1 Day from now
    iat: currentTime,
    sub: tokenPayload.userId,
    ...tokenPayload,
  };
  return (await JWS.createSign(signOptions, key).update(JSON.stringify(payload)).final()).toString();
};
```

![encoder](https://media.giphy.com/media/26FeUB5aTyCZkr4ru/giphy.gif "Encoder")

Stack-wise it's just a matter of creating the lambda and adding it to an endpoint

```typescript
const encoderFunction = new NodejsFunction(this, 'BlogCdkJwtEncoderFn', {
  ...lambdaProps,
  entry: join(__dirname, './encoder-ring.ts'),
});
secret.grantRead(encoderFunction);

restApi.root.addResource('encode').addMethod('POST', new LambdaIntegration(encoderFunction));
```

## Trying out our shiny new encoder

If we deploy those portions of the stack we could make a POST request like:

```bash
curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/encode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "groceryList": ["milk", "eggs", "healty stuff"]
}'
{"token":"eyJ0eXAiOiJqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6InZSRVlZVFg1TERFTGdGbHRzSmNWYmZDQUFCUTFITklmTG9HTDNHQXJDRTAifQ.eyJleHAiOjE2MTUxNDUzOTQsImlhdCI6MTYxNTA1ODk5NCwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJoZWFsdHkgc3R1ZmYiXX0.BXRWgNj7VR3cQwZJVOQGOzLcJhF3IIvEa624TlIDclafS63ITXLN2CZYq0vSFJtBHfPDxrkGRN5XPusfHeZnLHeX3HrVcYzxSL4jibH3bfq1ur_K23e4_5LVIKCkmWDduKewJ2cxgaXs5_7wRXQbCBdpPDFoXamxBKZXK1a_uWhZ4DjX4zFqfl2AZpF-yiuFIuMmKpzrxZ88rdaYjbtdsnX7CWc6I0TNf3i_3pGxzCZjY3UK8jKiHNmm4Lk2HBfRBT_8zvlVq2AhPOjjRHEUjqXKSp63rC16CcaM9sSYpM6YbtyucGKTltX9GrrJ_zBX6fSTDbgptq50hlyKeGY3tQ"}
```

But if you take the JWT and pop it into something like [jwt.io](jwt.io) you'd see this:

![Screen Shot 2021-03-06 at 2.32.13 PM.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1615059141933/tYrN-ckaS.png)

That's our list alright, but notice at the bottom the "Invalid Signature" part 🧐

If you use a JWT editor you could sneaky edit it to look like: `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTUwNjI5OTUsImlhdCI6MTYxNTA1ODk2NSwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJjb29raWVzIiwiY2FuZHkiXSwianRpIjoiZjFlYjRiMTktYjQwYy00NWQ3LWIyZjgtYWNlMjY1NWNhZDliIn0.-B-8-m8hwmOWKSUI1B3wOmYaPWyegihVIi6U17D5H1M` which replaces "healthy stuff" with cookies and candy... and is just as invalid-looking in [jwt.io](jwt.io)

![Screen Shot 2021-03-06 at 2.37.00 PM.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1615059427666/Lj3DbEg0n.png)

# The Secret Decoder Ring

[So let's verify it.](https://github.com/martzcodes/blog-cdk-jwt/blob/main/src/decoder-ring.ts)  The encoder lambda had access to the private key... our decoder lambda doesn't.  It doesn't have access to anything except what's publicly available.

Using the [jwks-rsa](https://www.npmjs.com/package/jwks-rsa) library we create a client that points to our publicly available jwks.json url and verify and decode the token.

```typescript
export const verifyToken = async (client: JwksClient, token: string) => {
  const decoded = decode(token, { complete: true, json: true });
  if (!decoded || !decoded.header.kid) {
    console.error('No Key ID');
    throw new Error('Token has no Key ID');
  }

  const key = await getSigningKey(client, decoded.header.kid);
  const signingKey = key.getPublicKey();

  if (!signingKey) {
    console.error('No public or rsa key');
    throw new Error('Unauthorized');
  }

  return verify(token, signingKey);
};
```

It decodes the JWT and then gets the correct public key to verify it with it with.  The response is then the decoded payload (verify does both).

![decoder ring](https://media.giphy.com/media/eiZeQ3Qzw1Fy8/giphy.gif "Decoder Ring")

The decoder portion of the stack is then simply:

```typescript
const decoderFunction = new NodejsFunction(this, 'BlogCdkJwtDecoderFn', {
  ...lambdaProps,
  entry: join(__dirname, './decoder-ring.ts'),
});

restApi.root.addResource('decode').addMethod('POST', new LambdaIntegration(decoderFunction));
```

So now we can take our good token and see:

```bash
curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/decode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "jwksUri": "https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/.well-known/jwks.json",
    "token": "eyJ0eXAiOiJqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6InZSRVlZVFg1TERFTGdGbHRzSmNWYmZDQUFCUTFITklmTG9HTDNHQXJDRTAifQ.eyJleHAiOjE2MTUxNDUzOTQsImlhdCI6MTYxNTA1ODk5NCwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJoZWFsdHkgc3R1ZmYiXX0.BXRWgNj7VR3cQwZJVOQGOzLcJhF3IIvEa624TlIDclafS63ITXLN2CZYq0vSFJtBHfPDxrkGRN5XPusfHeZnLHeX3HrVcYzxSL4jibH3bfq1ur_K23e4_5LVIKCkmWDduKewJ2cxgaXs5_7wRXQbCBdpPDFoXamxBKZXK1a_uWhZ4DjX4zFqfl2AZpF-yiuFIuMmKpzrxZ88rdaYjbtdsnX7CWc6I0TNf3i_3pGxzCZjY3UK8jKiHNmm4Lk2HBfRBT_8zvlVq2AhPOjjRHEUjqXKSp63rC16CcaM9sSYpM6YbtyucGKTltX9GrrJ_zBX6fSTDbgptq50hlyKeGY3tQ"
}'
{"exp":1615145394,"iat":1615058994,"groceryList":["milk","eggs","healty stuff"]}
```

And our bad token and see:

```bash
curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/decode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "jwksUri": "https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/.well-known/jwks.json",
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTUwNjI5OTUsImlhdCI6MTYxNTA1ODk2NSwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJjb29raWVzIiwiY2FuZHkiXSwianRpIjoiZjFlYjRiMTktYjQwYy00NWQ3LWIyZjgtYWNlMjY1NWNhZDliIn0.-B-8-m8hwmOWKSUI1B3wOmYaPWyegihVIi6U17D5H1M"
}'
Token has no Key ID
```

Which implies we don't know what signed this so DON'T TRUST IT.

![denied](https://media.giphy.com/media/hHh2lqWFeHYBO/giphy.gif "DENIED")

# Secret Manager Costs

At the time of this writing, Secrets Manager isn't _super_ cheap 💸

```
PER SECRET PER MONTH
$0.40 per secret per month. A replica secret is considered a distinct secret and will also be billed at $0.40 per replica per month. For secrets that are stored for less than a month, the price is prorated (based on the number of hours.)

PER 10,000 API CALLS
$0.05 per 10,000 API calls.
```

It's a recurring bill if you keep stuff around, so if you played with this make sure you destroy it afterwards and check via console to make sure you don't have anything else lingering around.

**MORE IMPORTANTLY: If it's a frequently invoked lambda you should evaluate some sort of caching strategy for the secret so you don't have to request it every time.  If the same container gets used 100 times and you cache the secret... you just saved a nice chunk of change.  🤔 **