Article summary
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 addAuthorizationType: AWS_IAM
to the route properties. - For routes defined within an OpenAPI spec (i.e. the
Body
property of anAWS::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.