A high-performance token-based API Gateway authorizer Lambda that can validate OIDC-issued JWT tokens.
This project provides an easy-to-install AWS Lambda function that can be used as a custom authorizer for AWS API Gateway. This authorizer can validate OIDC-issued JWT tokens and it can be used to secure your API endpoints using your OIDC provider of choice (e.g. Apple, Auth0, AWS Cognito, Azure AD / Microsoft Entra ID, Facebook, GitLab, Google, Keycloak, LinkedIn, Okta, Salesforce, Twitch, etc.).
A diagram illustrating how this project can be integrated.
A user sends an authenticated request to API Gateway. API Gateway is configured to use a custom lambda as an authorizer (THIS PROJECT!). The lambda talks with your OIDC provider to get the public key to validate the user token and responds to API Gateway to Allow or Deny the request.
API Gateway currently exists in 2 flavours: HTTP APIs and REST APIs. As of today, only HTTP APIs implement a built-in JWT authorizer that supports OIDC-issued tokens.
You might want to consider using this project in the following cases:
- You are using REST APIs and you want to secure your endpoints using OIDC-issued tokens. For instance, if you want to build APIs that are only available in a private VPC, you are currently forced to use REST APIs.
- You are using HTTP APIs but your OIDC provider gives you tokens that are not signed with the RSA algorithm (currently the only one supported by the built-in JWT authorizer).
- You want more flexibility in the validation process of your tokens. For instance, you might want to validate the
audclaim of your tokens against a list of values, instead of a single value (which is the only option available with the built-in JWT authorizer). - You want to customise the validation process even further. In this case, you can fork this project and customise the validation logic to your needs.
This custom Lambda Authorizer is designed to be easy to install and configure, cheap, highly performant, and memory-efficient. It is currently written in Rust, which is currently the fastest lambda Runtime in terms of cold start and it produces binaries that can provide best-in-class execution performance and a low memory footprint. Rust makes it also easy to compile the Authorizer Lambda for ARM, which helps even further with performance and cost. Ideally this Lambda, should provide minimal cost, even when used to protect Lambda functions that are invoked very frequently.
This project is meant to be integrated into existing applications (after all, an authorizer is useless without an API).
Different deployment options are available. Check out the deployment docs for an extensive explanation of all the possible approaches.
Alternatively, you can also consult some of the quick examples in the examples folder
If you prefer, you can also learn how to host your own SAR application.
The authorizer needs to be configured to be adapted to your needs and to be able to communicate with your OIDC provider of choice.
Here's a list of the configuration options that are supported:
- Environment variable:
JWKS_URI - Description: The URL of the OIDC provider JWKS (Endpoint providing public keys for verification).
- Mandatory: Yes
- Environment variable:
MIN_REFRESH_RATE - Description: The minimum number of seconds to wait before keys are refreshed when the given key is not found.
- Mandatory: No
- Default value:
"900"(15 minutes)
- Environment variable:
JWKS_PRE_CACHED_FILE_PATH - Description: Optional path to a pre-cached JWKS file on disk. When set, the authorizer pre-warms its in-memory key cache from this file at startup, avoiding the initial network call to the JWKS endpoint. This can significantly improve cold start performance. The file should contain a valid JWKS JSON structure matching your OIDC provider's JWKS format. If the file is missing or invalid, the authorizer logs a warning and starts with an empty cache (gracefully falling back to fetching from the JWKS URI on the first request). The file path should be accessible from the Lambda execution environment (e.g.,
/opt/jwks.jsonfor Lambda layers). - Mandatory: No
- Default value: Not set (disabled)
- Used together with:
LambdaLayers(when deploying via CloudFormation/SAR)
Tip
When the pre-warmed cache is active and a key is not found in it (e.g., because the OIDC provider has rotated keys), the authorizer fetches fresh keys from the JWKS endpoint and emits a structured log line with event_type=jwks_refresh_needed. You can use a CloudWatch Logs subscription filter to match this event and trigger an automated update of the pre-cached JWKS Lambda layer.
For a complete guide on creating and managing the JWKS layer (SAM, CDK, and AWS CLI approaches, plus automated rotation), see examples/jwks-lambda-layer/README.md.
- CloudFormation parameter (not an environment variable)
- Description: A comma-separated list of Lambda layer ARNs to attach to the authorizer function. Use this to add a pre-cached JWKS layer (for faster cold starts, together with
JwksPreCachedFilePath), monitoring extensions, or any other layers you need. This parameter is only relevant when deploying via CloudFormation or SAR. - Mandatory: No
- Default value:
""(no layers attached)
- Environment variable:
PRINCIPAL_ID_CLAIMS - Description: A comma-separated list of claims defining the token fields that should be used to determine the principal Id from the token. The fields will be tested in order. If there's no match the value specified in the
DefaultPrincipalIdparameter will be used. - Mandatory: No
- Default value:
"preferred_username, sub"
- Environment variable:
DEFAULT_PRINCIPAL_ID - Description: A fallback value for the Principal ID to be used when a principal ID claim is not found in the token.
- Mandatory: No
- Default value:
"unknown"
- Environment variable:
ACCEPTED_ISSUERS - Description: A comma-separated list of accepted values for the
issclaim. If one of the provided values matches, the token issuer is considered valid. If left empty, any issuer will be accepted. - Mandatory: No
- Default value:
""
- Environment variable:
ACCEPTED_AUDIENCES - Description: A comma-separated list of accepted values for the
audclaim. If one of the provided values matches, the token audience is considered valid. If left empty, any issuer audience be accepted. - Mandatory: No
- Default value:
""
- Environment variable:
ACCEPTED_ALGORITHMS - Description: A comma-separated list of accepted signing algorithms. If one of the provided values matches, the token signing algorithm is considered valid. If left empty, any supported token signing algorithm is accepted. Supported values:
ES256,ES384,RS256,RS384,PS256,PS384,PS512,RS512,EdDSA - Mandatory: No
- Default value:
""
- Environment variable:
TOKEN_VALIDATION_CEL - Description: A CEL (Common Expression Language) expression for custom token validation. The expression is evaluated against the decoded token's
headerandclaimsafter signature verification. If the expression evaluates tofalse, the token is rejected. If empty, CEL validation is skipped. - Mandatory: No
- Default value:
""
Available variables:
header- JWT header fields (alg,kid,typ, etc.)claims- JWT payload claims (iss,sub,aud, custom claims, etc.)
Supported features:
- Boolean operators:
&&,||,! - Comparisons:
==,!=,<,>,<=,>= - String methods:
startsWith(),endsWith(),contains(),matches() - List macros:
exists(),all() - Presence check:
has() - Membership:
in
Example expressions:
claims.email_verified == true
claims.roles.exists(r, r == "admin")
!has(claims.acr) || claims.acr == "urn:mfa"
Tip
You can test your CEL expressions using the CEL Playground. Use the following example as a starting point:
Expression:
claims.email_verified == true && claims.roles.exists(r, r == "admin")
Input (YAML):
header:
alg: "RS256"
kid: "my-key-id"
typ: "JWT"
claims:
iss: "https://auth.example.com"
sub: "user-123"
aud: "my-api"
exp: 1735689600
iat: 1735686000
email: "user@example.com"
email_verified: true
roles:
- "user"
- "admin"- Environment variable:
AWS_LAMBDA_LOG_LEVEL - Description: The log level used when executing the authorizer lambda. You can set it to DEBUG to make it very verbose if you need more information
to troubleshoot an issue. In general, you should not change this, because if you produce more logs than necessary that might have an impact on cost.
Allowed values:
TRACE,DEBUG,INFO,WARN,ERROR. - Mandatory: No
- Default value:
"INFO"
- Environment variable: N/A (only applies to CloudFormation deployments)
- Description: Optional. The name or ARN of an existing CloudWatch Log Group to use for Lambda logs. If specified, the Lambda will log to this group instead of the auto-created one. When using this option, you manage the log group's retention externally.
- Mandatory: No
- Default value:
""(uses auto-created log group)
- Environment variable: N/A (only applies to CloudFormation deployments)
- Description: The number of days to retain logs in the auto-created CloudWatch Log Group. Set to 0 for unlimited retention (default). This is useful for cost optimization.
- Mandatory: No
- Default value:
0(unlimited retention) - Allowed values:
0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653
Note
LogGroupName and LogRetentionDays are mutually exclusive. Use LogGroupName to direct logs to an existing log group you manage externally, OR use LogRetentionDays to have the stack create a managed log group with the specified retention. Specifying both will cause deployment to fail.
- Environment variable: N/A (only applies to CloudFormation deployments through SAR)
- Description: A prefix to be used for exported outputs. Useful if you need to deploy this stack multiple times in the same account.
- Mandatory: No
- Default value:
""
For instance, If you set this parameter to "Test", the ARN of the deployed authorizer when using SAR will be exported as "TestOidcAuthorizerArn".
The following section describes the steps that are followed to validate a token:
- The token is parsed from the
Authorizationheader of the request. It is expected to be in the formBearer <token>, where<token>needs to be a valid JWT token. - The token is decoded and the header is parsed to extract the
kid(key id) and thealg(algorithm) claims. If thekidis not found, the token is rejected. If thealgis not supported, the token is rejected. - The
kidis used to look up the public key in the in-memory JWKS (JSON Web Key Set) cache. IfJWKS_PRE_CACHED_FILE_PATHis configured, the cache is pre-warmed from the file at startup so keys are immediately available without a network call. If the key is not found in the cache, the JWKS is refreshed from the OIDC provider and the lookup is retried. If the key is still not found, the token is rejected. The JWKS cache is optimistic: it does not automatically refresh keys unless a lookup fails, and it rate-limits refresh attempts (configurable viaMIN_REFRESH_RATE). - The token is decoded and validated using the public key. If the validation fails, the token is rejected. This validation also checks the
exp(expiration time) claim and thenbf(not before) claim. If the token is expired or not yet valid, the token is rejected. - The
iss(issuer) claim is checked against the list of accepted issuers. If the issuer is not found in the list, the token is rejected. If the accept list is empty, any issuer is accepted. If the token contains multiple issuers (array of strings), this check will make sure that at least one of the issuers in the token matches the provided list of accepted issuers. - The
aud(audience) claim is checked against the list of accepted audiences. If the audience is not found in the list, the token is rejected. If the list is empty, any audience is accepted. If the token contains multiple audiences (array of strings), this check will make sure that at least one of the audiences in the token matches the provided list of accepted audiences. - If a CEL expression is configured (
TOKEN_VALIDATION_CEL), it is evaluated against the token'sheaderandclaims. If the expression evaluates tofalse, the token is rejected. If the expression cannot be evaluated (parse/type/eval error), the token is rejected (fail closed). If no CEL expression is configured, this step is skipped. - If all these checks are passed, the token is considered valid and the request is allowed to proceed. The principal ID is extracted from the token using the list of principal ID claims. If no principal ID claim is found, the default principal ID is used.
The authorizer enriches the context of the request with the following values:
principalId: the principal ID extracted from the token.jwtClaims: a JSON string containing the entire token payload (claims).
These values are injected into the context of the request and can be used to enrich your logging, tracing or to implement app-level authentication.
When you use the Lambda-proxy integration these values are made available under event.requestContext.authorizer.
For example, this is how you can access the principalId and jwtClaims values in a Lambda function written in Python:
import json
def handler(event, context):
print('principalId: ')
print(event['requestContext']['authorizer']['principalId'])
print('jwtClaims: ')
jwtClaims = json.loads(event['requestContext']['authorizer']['jwtClaims'])
print(jwtClaims)
return {'body': 'Hello', 'statusCode': 200}We have benchmarked this authorizer against an equivalent Python implementation. And these are some of the main findings:
- The Rust version is about 16 times faster than the Python version when it comes to cold starts (~42ms vs ~670ms).
- Execution times are quite comparable between the two implementations, with the Rust version being only slightly faster. This is probably because the Python library used to do the JWT validation is quite optimized.
- Memory utilization is about 3.5 times smaller in Rust (22MB vs 77MB). This allows us to use a smaller memory size for the Rust version, which results in a lower cost.
- The cost per request is about 3 times smaller in Rust compared to Python (~1.44 USD vs ~4.13 USD per every 100Mln invocations).
If you want to have a more detailed look at the benchmark methodology and the results, you can check out the dedicated benchmarking repository.
Everyone is very welcome to contribute to this project. You can contribute just by submitting bugs or suggesting improvements by opening an issue on GitHub.
Licensed under MIT License. Β© Luciano Mammino.
Big thanks to:
- @Lodewyk11 & @gsingh1 for writing the original Python implementation that inspired this work.
- @eoinsha for suggesting various ways to package and distribute this project.
- @allevo for tons of great Rust suggestions.
- @alexdebrie for his amazing article on custom ApiGateway Authorizers.

