Securing an API Gateway Using AWS IAM

AWS API Gateway provides a convenient “front door” to other services, which can be accessed directly by users or by other parts of your own infrastructure. For user access, AWS Cognito is a good choice for authentication and authorization. But when it only involves machines, AWS IAM may be a better fit.

Configuring API Gateway

The first step is to enable an IAM authorizer on your API Gateway routes. The exact method depends on how you define your routes:

  • For routes declared as AWS::ApiGatewayV2::Route resources, just add AuthorizationType: AWS_IAM to the route properties.
  • For routes defined within an OpenAPI spec (i.e. the Body property of an AWS::ApiGatewayV2::Api), it’s complicated but possible
  • Your’e out of luck for routes generated by SAM from HttpApi Events. As of this writing, SAM template types do not support the AWS_IAM authorizer; but check the status of this PR to be sure.

The next step is to define policies for accessing your routes. There are many ways you could go about this, but I like to define managed policies and attach them where needed. The important part is that Action: execute-api:Invoke is what grants permission to invoke an API Gateway route.

The following is a simple policy that would grant access to all routes. If needed, you could build more specific policies by replacing those wildcards (at the end of the “Resource” value) with HTTP methods and/or paths.

  MySimplePolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          Effect: Allow
          Action: execute-api:Invoke
          Resource: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${MyApiGateway}/*/*/*"

You can attach a policy like this to a role using the ManagedPolicyArns property of its CloudFormation template. That role could be one that runs a service needing to access your API Gateway, or it could be a role assumed by a human user.

Signing Requests

To access an API Gateway protected by IAM, your client needs to not only have the right AWS credentials, but it also needs to sign requests. This involves producing a hash from the important bits of the request and then signing it using those AWS credentials. Fortunately, there are libraries to handle most of the details.

Axios is a popular HTTP client. The signature process can be added to an existing Axios-based client pretty transparently since it just adds a few extra headers.

The following NPM packages will do the heavy lifting:

  • @aws-crypto/sha256-js
  • @aws-sdk/protocol-http
  • @aws-sdk/credential-provider-node
  • @aws-sdk/signature-v4

I always start by creating a new Axios client (rather than by modifying the global default one).

import axios, { AxiosRequestConfig } from "axios";

// For some reason, axios default content-type is application/x-www-form-urlencoded
const defaultHeaders = {
  "Content-Type": "application/json",
};

const client = axios.create({
  baseURL,
  headers: {
    common: {
      host: apiUrl.hostname, // AWS signature V4 requires this header
    },
    post: defaultHeaders,
    put: defaultHeaders,
    patch: defaultHeaders,
  },
});

One quirk about Axios is that, although it may infer the Content-Type of data in a request, this info doesn’t make it into interceptors. So, since my APIs all deal communicate in JSON, I set the default Content-Type.

Next we can attach the interceptor that will sign the request.

client.interceptors.request.use(async (config) => {
  // This is mainly for typescript's benefit; we expect method and url to be present
  if (!(config.method && config.url)) {
    throw new Error("Incomplete request");
  }
  // Axios somewhat annoyingly separates headers by request method.
  // We need to merge them so we can include them in the signature.
  const inputHeaders = {
    ...config.headers.common,
    ...(config.headers[config.method] ?? {}),
  };

  const signedRequest = await signRequest(
    inputHeaders,
    config.method,
    config.url,
    config.data
  );
  const outputConfig: AxiosRequestConfig = {
    ...config,
    headers: { [config.method]: signedRequest.headers },
  };
  return outputConfig;
});

The config object that gets passed into the interceptor is a combination of client configuration as well as properties for the current request. The important parts are passed to signRequest, and the resulting headers get merged into the current request’s headers before it is sent.

The signRequest function uses some AWS libraries to produce the necessary headers, which are returned to axios in order to actually send the request.

import { Sha256 } from "@aws-crypto/sha256-js";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { SignatureV4 } from "@aws-sdk/signature-v4";

async function signRequest(
  headers: Record<string, string>,
  method: string,
  path: string,
  body?: unknown
) {
  const request = new HttpRequest({
    body: body ? JSON.stringify(body) : undefined,
    headers,
    hostname: apiUrl.hostname,
    method: method.toUpperCase(),
    path,
  });

  const signer = new SignatureV4({
    region: region,
    credentials: defaultProvider(),
    service: "execute-api",
    sha256: Sha256,
  });

  const signedRequest = await signer.sign(request);
  return signedRequest;
}

What’s happening here is that the SignatureV4 library is calculating a hash using the given request details. On the other end of the request, the IAM authorizer is doing the same thing.

This is the most important part to get right because if anything is off, the computed hash won’t match and API Gateway will simply respond with “403 Forbidden.” If this happens, try debugging a GET request first, since those are free from the complexity that body brings.

Also notice the defaultProvider() used for credentials. You could store and supply credentials yourself, but this conveniently reads them from the current environment (deployed or local). For a human user who has installed and logged into aws-cli, there is nothing else you need to do.

API Gateway Secured with IAM

If everything is set up correctly, you can now make requests to an API Gateway secured with IAM.