Rotate AppSync API Key in an AWS Amplify project

Andy BrunnerTechnik

If you are using AppSync’s GraphQL API in an AWS Amplify project and your application does not have a user login, which means that your API is publicly available, your API is probably protected by an APIKey and you are likely to face the problem that this APIKey expires after one year at the latest.

Unfortunately, there is no native way to automatically rotate the API key. In the Amplify docs we can only find a way to do this manually. But we don’t like manual tasks, do we? So we are going to automate this.

When we thought about it, we saw two possible ways. The first was, automating the steps described in the Amplify docs (changing the parameter file and calling amplify push) in a CodePipeline. The second was, rotating the API key with a Lambda and provide the new API key to the frontend. We opted for the latter.

Content

Here is what we are going to do:

  • Adjust the frontend code, so that the Amplify config reads the API key from a separate file.
  • Change hosting of the frontend from Amplify console to S3 and CloudFront.
  • Prevent Amplify from creating the API key by itself
  • Write a Lambda function that creates a new API key and puts it into the key file of the frontend hosting.
  • Passing the AppSync API ID to the Lambda function.

Adjusting Amplify configuration of the frontend

Since our application has a frontend written in Javascript(React), this section covers only this language. But this should be pretty easy to translate into other languages.

You probably know the following two lines from your project:

import awsconfig from './aws-exports';
Amplify.configure(awsconfig);

If you look in the aws-exports.js of your project, you will see the API key stored there. We now change this part so that the API key is taken from a separate file. This way it is much easier to replace it afterwards. We create a new *.txt file in our src directory and call it whatever we want. Then, in our case we have to call the Amplify.configure(config) inside an async/await function, so that the following API calls wait for Amplify being configured.

import awsconfig from "./aws-exports";
import ask from './askrotreg.txt'
...
...
async function doSomeAPIStuff() {
    // grab appsync api key from txt file. It's in there so that it's easier to rotate the key
    await fetch(ask).then(r => r.text()).then(key => {
        let config = awsconfig ;
        config.aws_appsync_apiKey = key;
        Amplify.configure(config);
    });
    
    // call your API Query
    let regions = await API.graphql({query: gqlQueries.getRegions});
}
doSomeAPIStuff();

Change hosting

To be able to access the files of the hosted frontend, we may have to change the default hosting method of our Amplify project from Amplify console to S3 and CloudFront. Check out the documentation. We run the following command.

  • Call the command: amplify add hosting
  • Chose Amazon CloudFront and S3.
  • Get more information from the documentation.

Now we open the newly created template amplify/backend/hosting/S3AndCloudFront/template.json. At the end of the template, at the Outputs part, we add the Export part to the HostingBucketName to make it accessible to our Lambda function.

    "Outputs": {
        "Region": {
            "Value": {
                "Ref": "AWS::Region"
            }
        },
        "HostingBucketName": {
            "Description": "Hosting bucket name",
            "Value": {
                "Ref": "S3Bucket"
            },
            "Export": {
                "Name": {
                    "Fn::Sub": "HostingBucketName-${env}"
                }
            }
        },

Prevent Amplify from creating the API key by itself

To avoid future issues with inconsistencies in our CloudFormation stacks, we disable Amplify from creating an API key. Otherwise, an API key might have expired and been deleted, and CloudFormation expects it to still exist. This could ruin our whole deployment.

Corresponding to the documentation, we open the file amplify/backend/api/<apiName>/parameters.json and add the following line.

{
  "CreateAPIKey": 0
}

The next time we run the command amplify push, the GraphQL API resource is published without its own API key. Simply that you know. This will cause Amplify to remind us that no API key is configured on every amplify push in the future. However, that is fine for us.

GraphQL API is configured to use API_KEY authentication, but API Key deployment is disabled, don't forget to create one.

Lambda function that rotates the API key

Alright. Here comes the smart part. We solve the rotation of the key with a scheduled Lambda function.

  • Create a new Lambda function (amplify add function)
  • In our case, we have chosen python as runtime
  • Chose the Hello world template
  • As we want to rotate the API key on a schedule, answer the follwing with yes
    Do you want to invoke this function on a recurring schedule?
  • Chose the interval (to us, once a week seemed appropriate)

Now we put the following code into our index.py.

import boto3
from botocore.exceptions import ClientError
import json
import os
import time
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def replaceApiKey(key: str):
    bucket = os.environ['HOSTINGBUCKET']
    localFn = '/tmp/AppSyncKeyRotatetRegularly.txt'

    # boto3 client
    s3c = boto3.resource('s3')
    bucket = s3c.Bucket(bucket)

    keyfile = list(bucket.objects.filter(
        Prefix='static/media/askrotreg'))[0].Object()
    if keyfile:
        logger.info(
            f'File with API Key found on hosting bucket: s3://{bucket.name}/{keyfile.key}')

        logger.info('Put API key in a local temp file')
        f = open(localFn, "w")
        f.write(key)
        f.close()

        logger.info(
            'Replace key file on hosting bucket and grant read permission to everyone.')
        keyfile.upload_file(localFn, ExtraArgs={
                            'GrantRead': 'uri="http://acs.amazonaws.com/groups/global/AllUsers"'})


def handler(event, context):
    try:
        asc = boto3.client('appsync')
        response = asc.create_api_key(
            apiId=os.environ['APIID'],
            description=f'Autogenerated APIKey: {time.strftime("%d.%m.%Y %H:%M:%S%z")}',
            expires=(round(time.time())+(2*604800))
        )
        logger.info(f'boto3.Create_API_Key response: {json.dumps(response)}')
    except ClientError as e:
        return {'message': e.response['Error']['Message'], 'status': 'fail'}

    if response:
        logger.info(f"New API Key created: {response['apiKey']['id']}")
        logger.info(
            f"Key expires on: {time.strftime('%d.%m.%Y %H:%M:%S%z',time.localtime(response['apiKey']['expires']))}")
        logger.info(
            f"Key deletes on: {time.strftime('%d.%m.%Y %H:%M:%S%z',time.localtime(response['apiKey']['deletes']))}")
        replaceApiKey(response['apiKey']['id'])

    return

What this Lambda function does:

  • Create a new API key on your AppSync API with your API ID
  • Put the API key into a temporary local *.txt file
  • Replace the original *.txt file of the frontend with our new one

Note, in line 43 we set the expiration time of the API key. Since we chose a rotation interval of one week, we set the expiration time to two weeks. This means that after a new key is created, the old one will continue to work for another week, as it may take some time for a new key to be provisioned through CloudFront. You may adjust this to your needs.

We are using two Lambda environment variables. One is called HOSTINGBUCKET, which we already exported before and is very easy to import, the other one is APIID, which is a little more tricky. We are going to cover this in the next section.

Passing the AppSync API ID to the Lambda function

In order for the Lambda function to create a new API key, it must know the API ID of our API. And if we check out the CloudFormation Outputs of our API in the console, we see that there is an export with a name similar to amplify-<yourApp>-prd-140415-<yourAPI>-CCQFNXFBY1UW:GraphQLApiEndpoint. But since we have no chance of knowing this name to import it into our Lambda’s CloudFormation template, we have to come up with a workaround. We need to know the Key of the ID:

Now we add two dependsOn values to the Lambda in the file amplify/backend/backend-config.json. More information on this here.

    "nameOfYourLambdaFunction": {
      "build": true,
      "providerPlugin": "awscloudformation",
      "service": "Lambda",
      "dependsOn": [
        {
          "category": "api",
          "resourceName": "resourceNameOfYourAPI",
          "attributes": [
            "GraphQLAPIIdOutput"
          ]
        },
        {
          "category": "hosting",
          "resourceName": "S3AndCloudFront",
          "attributes": [
            "HostingBucketName"
          ]
        }
      ]
    }

Now we add two new parameters to our Lambda’s CloudFormation template. The names of the parameters are a concatenation of <category><resourceName><attributes>.

  "Parameters": {
...
...
    "apiresourceNameOfYourAPIGraphQLAPIIdOutput": {
      "Type": "String"
    },
    "hostingS3AndCloudFrontHostingBucketName": {
      "Type": "String",
      "Default": "hostingS3AndCloudFrontHostingBucketName"
    }

In the same directory as the Lambda’s CloudFormation template, there should be a parameters.json, containing our CloudWatchRule schedule. We extend it with the information about our API ID. This way we pass the ID output as a parameter to the Lambda template.

{
  "CloudWatchRule": "cron(0 4 ? * 1 *)",
  "apiresourceNameOfYourAPIGraphQLAPIIdOutput": {
    "Fn::GetAtt": [
      "resourceNameOfYourAPI",  
      "Outputs.GraphQLAPIIdOutput"
    ]
  }
}

Now, we just have to pass the parameters as environment variables to the Lambda function itself. We extend the Environment part in the Lambda’s CloudFormation template with our two variables.

        "Environment": {
          "Variables": {
            "ENV": {
              "Ref": "env"
            },
            "REGION": {
              "Ref": "AWS::Region"
            },
            "HOSTINGBUCKET": {
              "Fn::ImportValue": {
                "Fn::Sub": "HostingBucketName-${env}"
              }
            },
            "APIID": {
              "Ref": "apiresourceNameOfYourAPIGraphQLAPIIdOutput"
            }
          }

Permissions

You probably knew this was coming. Nothing works without the right permissions. We need to allow our Lambda the creation of API keys and reading and writing objects on our S3 hosting bucket.

In our Lambda’s CloudFormation template we look for the resource lambdaexecutionpolicy, and add the following to the Statement part.

        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
...
...
            {
              "Effect": "Allow",
              "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:PutObjectAcl",
                "s3:ListBucket"
              ],
              "Resource": [
                {
                  "Fn::Sub": [
                    "arn:aws:s3:::${bucketName}/*",
                    {
                      "bucketName": {
                        "Fn::ImportValue": {
                          "Fn::Sub": "HostingBucketName-${env}"
                        }
                      }
                    }
                  ]
                },
                {
                  "Fn::Sub": [
                    "arn:aws:s3:::${bucketName}",
                    {
                      "bucketName": {
                        "Fn::ImportValue": {
                          "Fn::Sub": "HostingBucketName-${env}"
                        }
                      }
                    }
                  ]
                }
              ]
            },
            {
              "Effect": "Allow",
              "Action": [
                "appsync:CreateApiKey"
              ],
              "Resource": "*"
            }
          ]
        }

Here we go. This should be everything needed to automatically rotate an API key of an Amplify project!

If I have forgotten anything or you have any suggestions for improvement, please feel free to let me know.

Best regards
Andy / cloudxs