How to deploy an AWS AppSync API behind WAF using CDK

In this tutorial, we’ll look at how you can leverage AWS CDK v2 to deploy an AppSync GraphQL API behind AWS WAF and use managed rules to protect against the most common web vulnerabilities.

Here at Instil, we’ve made the decision to adopt AWS’s Cloud Development Kit (CDK) company-wide for deploying services to AWS. There were a multitude of reasons why we came to this decision, but the most important to us was how developer-friendly it is.

CDK offers APIs for a range of programming languages including TypeScript, which is our main language here at Instil and is what I’ll be using for this tutorial.

AWS has announced that CDK v1 will be entering maintenance mode as of June 1st 2022. With that in mind, I opted to use CDK v2 for this tutorial. Additionally, I am more familiar with CDK v1, so (selfishly) this was a very useful learning exercise!

Let’s introduce some services…

AWS CDK (v2)

The AWS Cloud Development Kit is an Infrastructure-as-Code library that leverages a range of well-loved programming languages, allowing you to build your infrastructure using common programming paradigms for repeatability and maintainability.

You can define your application’s resources using the CDK library, which is used to synthesise an AWS CloudFormation template that then can be used to provision them when you wish to deploy.

So what’s the difference between CDK v1 and CDK v2?

The key difference between the two is that CDK v2 is now maintained as a single centralised library, whereas CDK v1 was maintained as a set of disparate modules. As a result, importing and working with dependencies is much simpler in v2 with only two core imports, and therefore fewer dependencies to keep in sync.

For example, here’s how the dependencies within your package.json may look using CDK v1:

"dependencies": { 
  "@aws-cdk/aws-lambda": "^1.156.0", 
  "@aws-cdk/aws-appsync": "^1.156.0", 
  "@aws-cdk/aws-waf": "^1.156.0", 
  "@aws-cdk/core": "^1.156.0", 
  // rest of your dependencies ...
}

And here’s how it will look in CDK v2:

"dependencies": { 
  "aws-cdk-lib": "^2.24.1",
  "constructs": "^10.1.9",
  // rest of your dependencies ...
}

Additionally, it’s worth noting that so far there are fewer constructs provided by CDK v2 compared to CDK v1 at the time of writing. But definitely do keeping an eye out for updates to aws-cdk-lib as they’ll add constructs for more services over time.

For reference, any interface used below with the prefix Cfn is not a CDK construct but is an interface that is directly translated into CloudFormation.

AWS AppSync

In addition to adopting CDK for deploying our applications, we’ve also adopted GraphQL for writing our APIs. We also try to leverage serverless wherever possible. Therefore, it was a natural decision for us to adopt AWS AppSync.

In a few words, AppSync provides an AWS-managed GraphQL service, with first-party support for securely communicating with other AWS services to resolve queries or mutations. For instance, you can use VTL templates to communicate directly with DynamoDB, or you can call a lambda function in response to a query/mutation.

AWS Lambda

At Instil, we’re invested in building serverless, event-driven applications where possible. So with that in mind, I’m going to drive my example AppSync API with an AWS Lambda function.

AWS Lambda allows you to run a slice of your application code as a single function, without thinking about provisioning the underlying resources required. This is achieved by simply uploading the source code, and defining an event that’s used to call the function. For example, you can trigger a lambda by requesting some data via AppSync 😉.

AWS WAF

With security and availability being top priorities of ours, we also thoroughly recommend deploying web applications and APIs behind AWS WAF.

AWS WAF provides a managed web application firewall, which can be deployed to help protect your web applications and APIs. Using AWS managed rule sets, WAF can offer protection against common web exploits such as SQL injection or XSS, as well as protection from bots, and other suspicious traffic. Additionally, you can subscribe to rule sets managed by third-parties, or create custom rules to detect and restrict more specific usage patterns. For instance, you could create a custom rule to geo-restrict traffic inbound from suspicious locations.

Time to dive into some code...

Click here to check out the code for this tutorial.

If you’re setting up your first CDK project…

If this is your first time using the AWS CLI/AWS CDK, then I’d recommend checking out AWS’s guide to getting started with the AWS CDK. There are a couple of steps you must follow there before you’re ready to deploy your application, including configuring the AWS CLI and bootstrapping your account with the necessary CDK resources.

Create your AppSync API and an API key

The first thing we need to create for AppSync is a GraphQL API, this is done via the CfnGraphQLApi class provided by CDK:

readonly api = new CfnGraphQLApi(this, "graphql-api-id", { 
    name: "graphql-api-name",
    authenticationType: "API_KEY",
});

The authenticationType is required in order to authorise users/applications to allow them to interact with your API. Using an API key creates an unauthenticated API for public availability, you can also use AWS IAM and Cognito User Pools as means of authentication if required.

Next you create your API key resource, and associate it with your GraphQL API:

private readonly apiKey = new CfnApiKey(this, "graphql-api-key", { 
    apiId: this.api.attrApiId, 
});

Create your API’s GraphQL Schema

After creating your API and its API key, you provide its GraphQL schema using the CfnGraphQLSchema class and associate the schema with your API using its apiId.

You can provide the schema definition in one of two ways:

  • Using the definition property, which provides the GraphQL schema as a string and will be injected directly into the CloudFormation output;
  • Alternatively, you can provide the location of your schema in S3 using the definitionS3Location property.

For this example, I’ve written the schema in a file found at src/graphql/schema.graphql and I’m then reading this file as a string which is passed to the constructor:

private readonly schema = new CfnGraphQLSchema(this, "graphql-api-schema", { 
    apiId: this.api.attrApiId, 
    definition: readFileSync("./src/graphql/schema.graphql").toString(),
});

If the schema you’ve provided is not well-formed, then CDK will throw an error at deploy time.

Create your lambda, and allow AppSync to call it

Next, we’ll create a lambda function that we’ll use to respond to requests to our GraphQL API, as well as the plumbing required in order for AppSync to call it.

Since I’ve written a TypeScript lambda, I can take advantage of the NodejsFunction class to create my lambda and point it directly at the source code. Using this interface, I don’t need to build the code myself ahead of deployment, as the NodejsFunction class will handle that for me using esbuild!

private readonly lambdaFunction = new NodejsFunction(this, "lambda-function-id", { 
    entry: "./src/lambda/index.ts", 
    handler: "handler", 
    functionName: "lambda-function-name", 
    runtime: Runtime.NODEJS_14_X, 
});

In order for AppSync to be able to invoke my lambda function, it requires the lambda:InvokeFunction policy for the lambda resource. So the next step is to create a role for AppSync, with the required policy:

private readonly invokeLambdaRole = new Role(this, "AppSync-InvokeLambdaRole", {
    assumedBy: new ServicePrincipal("appsync.amazonaws.com"), 
});

This creates a new role to be assumed by AppSync, and below we’ll add the invoke lambda policy to AppSync’s role with our lambda’s ARN. This is how we give AppSync the permission to call our lambda function:

this.invokeLambdaRole.addToPolicy(new PolicyStatement({ 
    effect: Effect.ALLOW, 
    resources: [this.lambdaFunction.functionArn],
    actions: ["lambda:InvokeFunction"] 
}));

Add your lambda datasource and resolver

AppSync requires any resources you’d like to use as GraphQL resolvers to be configured as a data source. There are several services that can be used as data sources, but for this tutorial we’re configuring our lambda as a data source:

private readonly lambdaDataSource = new CfnDataSource(this, "lambda-datasource", { 
    apiId: this.api.attrApiId,
    // Note: property 'name' cannot include hyphens
    name: "LambdaDataSource", 
    type: "AWS_LAMBDA", 
    lambdaConfig: {
        lambdaFunctionArn: this.lambdaFunction.functionArn 
    },
    serviceRoleArn: this.invokeLambdaRole.roleArn 
});

After creating your data source, you can create the resolver you’d like that data source to be associated with:

private readonly lambdaResolver = new CfnResolver(this, "lambda-resolver", {
    apiId: this.api.attrApiId, 
    typeName: "Query", 
    fieldName: "messages",
    dataSourceName: this.lambdaDataSource.name, 
});

In the above example, I’d like the messages field of the Query type for my API to be resolved using my lambda’s data source.

Lastly, we add a dependency between our resolver and the GraphQL schema:

this.lambdaResolver.addDependsOn(this.schema);

This prevents our resolver from being created before the schema, as this can cause errors whereby the resolver does not recognise the types and fields referenced when creating the resolver.

Adding AWS WAF protection to your AppSync API

In order to add AWS WAF to any of their supported web application services, you must first create a Web ACL and then you can associate it with the service that you’d like to protect.

The Web ACL is used to describe the firewall rules that you would like to apply to requests inbound on your service. For this example, I’ll just add the AWS Managed Rules Common Rule Set:

private readonly webAcl = new CfnWebACL(this, "web-acl", { 
    defaultAction: { 
        allow: {}
    }, 
    scope: "REGIONAL", 
    visibilityConfig: { 
        cloudWatchMetricsEnabled: true, 
        metricName: "webACL",
        sampledRequestsEnabled: true
    }, 
    rules: [ 
        { 
            name: "AWS-AWSManagedRulesCommonRuleSet",
            priority: 1, 
            overrideAction: {
                none: {}
            },
            statement: {
                managedRuleGroupStatement: { 
                    name: "AWSManagedRulesCommonRuleSet",
                    vendorName: "AWS", 
                }
            },
            visibilityConfig: {
                cloudWatchMetricsEnabled: true,
                metricName: "awsCommonRules",
                sampledRequestsEnabled: true 
            } 
        },
    ]
});

Adding the above ruleset will protect your web application from a range of common web exploits, including the OWASP Top 10.

Next, we create an association between the Web ACL and the AppSync API by passing their respective ARNs to the CfnWebACLAssociation interface:

private readonly appSyncAssociation = new CfnWebACLAssociation(this, "web-acl-association", {
    webAclArn: this.webAcl.attrArn, 
    resourceArn: this.props.apiArn 
});

And that’s it…

You’ve created and deployed a secure, highly available, and serverless GraphQL API! 💪

If you’d like to read some more AWS & CDK insights from the team here at Instil, then check out some of the lessons we've learnt, and some other related articles below. Otherwise, if you fancy delving deeper into how to leverage CDK and TypeScript to build your own serverless applications, then look no further than our TypeScript for AWS Serverless course.

Article By
blog author

Ross Jenkins

Software Engineer