To control access to ApiGateway, you can use Cognito or a Lambda authorizer. The official documentation covers this, but here’s a simple diagram for understanding:
ApiGateway access control is used, for example, when you want only logged-in users to be able to call the API. The simplest way to implement this is to use Cognito.
You can implement it just by specifying the Cognito UserPool you are using. If you are using AWS CDK, you can do it like this:
// For CDK (Typescript)
const userPool = new cognito.UserPool(this, 'UserPool');
const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'booksAuthorizer', {
cognitoUserPools: [userPool]
});
declare const books: apigateway.Resource;
books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), {
authorizer: auth,
authorizationType: apigateway.AuthorizationType.COGNITO,
});
When Cognito authentication succeeds, a token is returned to the frontend, and you can use that token to access ApiGateway. However, there is a problem with this method: you cannot set the HttpOnly
attribute on the cookie.
Depending on your requirements, you may want to set the HttpOnly
attribute on the cookie. If you want to set something custom in the cookie or elsewhere, AuthorizationType.COGNITO
is not flexible enough. In that case, you can use a Lambda authorizer.
A Lambda authorizer means using a Lambda function to control access. This allows for very flexible access control. This time, I’ll show how to use Cognito for authentication and a Lambda authorizer for access control.
The requirement this time is to call an API after authentication. First, connect API Gateway and Lambda.
The Lambda needs permission to call API Gateway. Also, since this time the token is set in a cookie, specify method.request.header.Cookie
as the identitySource.
Since the cookie is set with the httpOnly
attribute, the frontend cannot retrieve the token from the cookie, so you need to set the cookie in the header for API Gateway. In the API Gateway’s requestParameters
, set "integration.request.header.Cookie": "method.request.header.Cookie",
.
With this, the associated Lambda authorizer will receive the cookie, so the Lambda function can perform authentication and access control.
// For CDK (Typescript)
// Create a role for the lambdaAuthorizer
const lambdaAuthorizerRole = new Role(this, "LambdaAuthorizerRole", {
roleName: LambdaAuthorizerRole,
assumedBy: new ServicePrincipal("apigateway.amazonaws.com"),
description: "Lambda Authorizer Role",
});
// Set up the lambdaAuthorizer
const lambdaAuthorizer = new TokenAuthorizer(this, "LambdaAuthorizer", {
handler: authorizerFunc, // Lambda function
identitySource: "method.request.header.Cookie",
assumeRole: lambdaAuthorizerRole,
});
// By specifying the lambdaAuthorizer like this, you can control access to API Gateway
ApiEndPoint.addMethod(
"POST",
new LambdaIntegration(Lambda),
{
authorizer: lambdaAuthorizer,
authorizationType: AuthorizationType.CUSTOM,
}
);
Now API Gateway and Lambda are linked. Next, create the Lambda function.
The Lambda function can be implemented as follows. Here, GetUserCommand
is used to get user information.
The params
for GetUserCommand
require an AccessToken
. If you simply pass the AccessToken to GetUserCommand
without proper validation, any AccessToken issued elsewhere could pass authentication, which is a security risk.
Therefore, you need to properly validate the AccessToken. You can use the well-known jsonwebtoken, but here we use aws-jwt-verify provided by AWSLabs.
import {
CognitoIdentityProvider,
GetUserCommand,
GetUserCommandInput,
GetUserCommandOutput,
} from "@aws-sdk/client-cognito-identity-provider";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { apiGatewayTokenAuthorizerEvent } from "aws-lambda";
async function handler(event: apiGatewayTokenAuthorizerEvent) {
// Get the cookies
const cookies = event.authorizationToken || "";
const userPoolId = process.env.USER_POOL_ID;
const clientId = process.env.CLIENT_ID;
const client = new CognitoIdentityProvider({});
// Get the accessToken
const accessToken = cookies
.split(";")
.find((row) => row.trim().startsWith("accessToken"))
?.split("=")[1];
// If the value could not be obtained
if (!accessToken || !userPoolId || !clientId) {
return {
principalId: "user",
policyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: "Deny",
Resource: "*",
},
],
},
};
}
try {
// Validate the accessToken
const verify = CognitoJwtVerifier.create({
userPoolId: userPoolId,
tokenUse: "access",
clientId: clientId,
});
const payload = await verify.verify(accessToken);
console.log("Token is valid. Payload:", payload);
// Get user information
const params: GetUserCommandInput = {
AccessToken: accessToken,
};
const command = new GetUserCommand(params);
const response: GetUserCommandOutput = await client.send(command);
const emailAttribute = response.UserAttributes?.find(
({ Name }) => Name === "email",
);
const email = emailAttribute?.Value;
return {
principalId: "user",
policyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: "Allow",
Resource: "*",
},
],
},
context: {
email: email,
},
};
} catch (error) {
console.error("Error:", error);
return {
principalId: "user",
policyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: "Deny",
Resource: "*",
},
],
},
};
}
}
export { handler };
In this case, the Lambda authorizer uses Cognito’s GetUserCommand
, but the Lambda authorizer does not have permission for GetUserCommand
. However, GetUserCommand
works fine.
This is because GetUserCommand
only retrieves the user from the AccessToken parameter and does not require any special UserPool or authentication information. At first, I wondered why it worked and checked various IAM role settings.
The point is, you should properly validate the AccessToken.
When authentication passes in the Lambda authorizer, the following is returned, but Resource
is set to *
.
return {
principalId: "user",
policyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: "Allow",
Resource: "*",
},
],
},
context: {
email: email,
},
};
At first, I thought I needed to specify the API Gateway ARN for Resource
, so I tried specifying the API Gateway ARN in accordance with best practices for minimal policy, but even when I specified an incorrect API Gateway ARN for testing, it still worked.
I wondered if this was a security issue, so I looked into it and found the reason.
When you link a Lambda authorizer and API Gateway, you specify the API, so even if you specify an incorrect API Gateway ARN in Resource
, it still works.
ApiEndPoint.addMethod(
"POST",
new LambdaIntegration(Lambda),
{
authorizer: lambdaAuthorizer,
authorizationType: AuthorizationType.CUSTOM,
}
);
If you think about it, with this code, the authorizer
is only applied to the specified API, so specifying *
for Resource
is not a problem.