Tobias Davis

Designing better software systems.

Free resources for building better systems, get on the email list, or use RSS.

Site logo image

Intro to AWS API Gateway Authorizers

There’s a great bit of official AWS documentation on API Gateway authorizers, but in this article I’m going to give you the short version, with some concrete examples. In future articles, I’ll do a deeper dive on more of the details.

Intro to API Gateway #

The API Gateway is a tool to build APIs that can serve all sorts of content: data formats like JSON and XML, human-viewable formats like HTML, constructed binary files like DOCX or PDF, and much much more.

Basically, define an HTTP endpoint and link it to some service, such as (typically) a Lambda or a DynamoDB table. The Lambda would execute code based on the request, and then return whatever is appropriate (HTML, JSON, etc). If you link to a DynamoDB table, you create a mapping of HTTP endpoint to DynamoDB operations (e.g. PutObject or GetObject, or Query with parameters coming from the request), and some transforms for the output.

Motivation for Authorizers #

Obviously you will eventually have HTTP endpoints that need authentication (cookie sessions, API tokens, etc) and the question is: where to authenticate?

If your API Gateway is linked to a Lambda, your first thought might be to put your authorization code inside the Lambda:

exports.handler = async (event, context) => {
if (user_is_authenticated(event)) {
// do the authenticated operation
} else {
// return a 401
}
};

However, running authentication code on each request could become expensive or slow down request processing enough that you start looking for other options.

Also, if you want to link an HTTP endpoint directly to some other service, such as POST requests to SQS, or directly to DynamoDB… how do you authorize those requests?

Intro to API Gateway Authorizers #

Enter the API Gateway “Authorizer”, a pre-execution-execution, it runs before the request triggers your integration (Lambda, DynamoDB, etc) and the results of the Authorizer function determine whether the integration actually runs.

This is absolutely required for integrations that tie directly to other services, e.g. SQS and DynamoDB. There’s no other way to authorize the request!

Note: Authorizers can either use a Cognito User Pool, or execute a Lambda function. In this article I’m describing Lambda functions, because I think they are generally a better option than Cognito.

For API Gateway integrations that execute Lambda code already, Authorizers aren’t necessary (you can always execute the same code in the integration Lambda), but they allow for separation of concerns, and caching the results of the Authorizer can give you quite a bit of performance and cost improvements.

You can define different Authorizers per HTTP endpoint, allowing you quite a bit of control. However, if you set the same Authorizer for a collection of endpoints, a cache event at one endpoint can be configured to mean a cache hit (of the Authorizer) for requests to the other endpoints, also increasing performance.

Lambda Authorizers also have a configurable cache time, which means future requests within that window don’t need to re-auth, so long as they have the same token.

The official diagram looks something like this:

Diagram of AWS Authorizers

An Authorizer event #

Authorizers run as Lambda functions, and are given information about the initiating request that looks like this:

{
"type": "TOKEN",
"methodArn": "arn:aws:execute-api:us-east-1:123456789:h0hrkql4qa/default/GET/mypath",
"authorizationToken": "abc123"
}

What you’re expected to return is either an HTTP response object if the authentication fails entirely, or a policy document to authorize the request to execute whatever your integration is.

You should return an HTTP response object if you don’t want the response cached, and you don’t want to allow the integration to execute:

exports.handler = async (event) => {
const token = event.authorizationToken
const user = await expensiveUserLookup(token)
if (!user) {
// Authorizer result will not cache
return {
statusCode: 401,
body: 'Unauthorized'
}
}
// other stuff
};

If you want the Authorizer result to be cached, or you want to authorize the integration to execute, you’ll need to return a policy document:

exports.handler = async (event) => {
const token = event.authorizationToken
const user = await expensiveUserLookup(token)
// If the user is authenticated, you'd return something
// like this:
return {
policyDocument: {
Statement: [{
// If the user isn't authenticated, use 'Block'
Effect: 'Allow',
// The action you need to set is to invoke
// the API's integration
Action: 'execute-api:Invoke',
// The ARN of the integration, which is given
Resource: event.methodArn
}]
},
// This gets passed along to the integration
principalId: user.id,
// You can pass along other arbitrary details
// to the integration as well
context: {
foo: 'bar',
// Usually you'd pass along useful things like the
// user's roles
roles: user.roles
}
}
};

Note that the data you can pass along on the context property is limited, but is still very useful.

Next Up #

There are a whole lot of details left to talk about, like how exactly the context property works, caching across HTTP requests, Authorizers for direct service integrations (like to SQS, DynamoDB, etc.), and more.

As I write more details down, I’ll update this with links to them.


Your thoughts and feedback are always welcome! 🙇‍♂️