Photo by Liam Tucker on Unsplash
Custom AWS Lambda Authorizer in Serverless Stack
Centralize your auth logic in a single Lambda, and let your other functions focus on business rules.
API Gateway provides a feature called Authorizers to help isolate your auth logic from the business logic in your application.
What is an Authorizer
An Authorizer is a function that is executed before your main function, and has the responsibility of authenticating and/or authorizing the request, allowing or rejecting it before it gets to your main function.
This allows you to have all your Authentication and Authorization logic in a single, centralized function. With a Custom Authorizer, you can also enrich the request context with profile information and whatever data your application wants to associate with an authenticated user.
Why a Custom Authorizer
AWS provides a JWT authorizer, which is ready-to-go and will ensure that a request carries a valid JWT token. You specify an issuer and an audience and API Gateway will automatically validate that for you.
With a Custom Authorizer, you take control of the Authentication and Authorization processes however you like. You can also cache responses, to speed up subsequent calls with the same already-validated token.
Configuring a Custom Authorizer
In your stack definition, add the defaultAuthorizationType
and defaultAuthorizer
as such:
// MyStack.ts
const authorizer = new sst.Function(thisArg, "AuthorizerFunction", {
environment: sharedEnvironmentVariables,
handler: "path/to/your/custom/auth.handler",
});
const api = new sst.Api(this, "MyApi", {
defaultAuthorizationType: sst.ApiAuthorizationType.CUSTOM,
defaultAuthorizer: new HttpLambdaAuthorizer("Authorizer", authorizer, {
authorizerName: "LambdaAuthorizer",
resultsCacheTtl: Duration.seconds(30),
}),
routes: {
"ANY /{proxy+}": "path/to/your/api.handler",
},
});
Implementing the Custom Authorizer
In your path/to/your/custom/auth.handler
, you basically need to validate the request token and decide whether to allow or deny access to your API handler.
The access is allowed or denied by returning a standard AWS IAM Policy, which is actually very straightforward, as you can see in the code below:
import { APIGatewayAuthorizerEvent, APIGatewayAuthorizerResult } from "aws-lambda";
import Authentication, { AuthenticationInfo } from "lib/infrastructure/Authentication";
const handler = async (event: APIGatewayAuthorizerEvent): Promise<APIGatewayAuthorizerResult> => {
const authentication = await authenticate(event);
// String, Boolean and Number are the only valid types.
// String values cannot contain \" as of this writing.
// There seems to be a 1mb restriction on the payload size,
// but the docs are not clear on none of these things.
const context = JSON.stringify({
someString: "some value",
someBoolean: true,
someNumber: 1
});
const result: APIGatewayAuthorizerResult = {
principalId: authentication.value?.id || "unkown",
policyDocument: buildPolicy(authentication.success ? "Allow" : "Deny", event.methodArn),
context,
};
return result;
};
async function authenticate(event: any): Promise<any> {
try {
const token = getTokenOrThrow(event);
const info = await Authentication.getUserInfo(token);
return { success: true, value: info };
} catch (error: any) {
return { success: false }
}
}
// The methodArn specifies exactly which function should be
// allowed ou denied access. You could use "*" to allow access
// to any of your functions, though it is always better to keep
// security tight.
function buildPolicy(effect: string, methodArn: string) {
return {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: effect,
Resource: methodArn,
},
],
};
}
const getTokenOrThrow = (event: any) => {
const auth = (event.authorizationToken || "");
const [scheme, token] = auth.split(" ", 2);
if ((scheme || "").toLowerCase() !== "bearer") {
throw new Error("Authorization header value did not start with 'Bearer'.");
}
if (!token?.length) {
throw new Error("Authorization header did not contain a Bearer token.");
}
return token;
};
export { handler };
The principalId
and policyDocument
are both required properties in the Authorizer response:
principalId
identifies the user or caller. It is usually the user ID, email or username.policyDocument
must be a valid IAM policy that allows or denies access to the underlying API Gateway resource that the user is trying to access.
As for the Authentication service, which validates the token against the Auth0 service in this case, the code is:
import axios from "axios";
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
type AuthenticationInfo = {
id: string;
email: string;
isEmailVerified: boolean
};
class Authentication {
static async getUserInfo(token: string): Promise<AuthenticationInfo> {
const url = `https://${AUTH0_DOMAIN}/userinfo`;
try {
const response = await axios.get(url, {
headers: {
authorization: "Bearer " + token,
},
});
return {
id: this.normalizeUserId(response.data.sub),
email: response.data.email,
isEmailVerified: response.data.email_verified,
};
} catch (error: any) {
throw new Error(`${error.response.status}: ${error.response.statusText}`);
}
}
private static normalizeUserId(auth0Id: string): string {
return auth0Id.substring("auth0|".length);
}
}
export default Authentication;
✅ If you want to include extra information available in your user_metadata
or app_metadata
, this article shows you how:
🚨 Friendly warning: just be extra cautious about the type restriction for your custom metadata, or there will be bugs! My advice is that you test every single tiny modification, so that you know exactly what the problem is right when it breaks. See this Stack Overflow thread.
Testing things out
This is all the setup you need.
With this, access to your function will only be allowed if the request sends a valid Bearer token in the Authorization header.
Fire up your application, get a token for your Auth0 user or any other service you're using, and send it with the request.
The first request will run through the Custom Authorizer function, and will be either allowed or denied.
Since we're caching, the result of this evaluation will hold for 30 seconds, or whatever it is the time you specified. For local tests, you could set it to 5 seconds or something, just so you can see your changes faster. For production, you can increase it up to 1 hour, though it is better to keep it shorter, in case your users are granted or denied access and you want to reflect that quickly in your application.
BONUS: Authentication vs Full Authorization
In the example above, we handle only Authentication.
Authentication is when you identify a user, meaning you figure out who it is that is accessing your services. It is like checking an ID document to make sure that a person is who they claim to be.
Authorization is when you decide whether someone should be allowed to do what they want, based on an access level or something. It is like someone at an airpoirt deciding if you should be allowed into a plane based on your check-in papers.
You could go ahead and implement Authorization together with Authentication all in one place, with the benefit of not having to that on a service-by-service basis, in a decentralized manner.
But Full Authorization is only recommended if you're not building with microservices, otherwise you're probably defeating the purpose of microservices, which is to allow teams to work independently of each other.