A Lambda authorizer is my tool of choice when an HTTP API isn’t built for people with a login but for machines with a key. A partner system pulls data every night, a hotel shows available rental bikes on its website, a repair shop reports finished jobs back. None of them has a user who signs in through Cognito. Still, not everybody who knows the URL should be able to query your API.
Unlike the older REST API, the HTTP API flavor of API Gateway has no built-in API keys. You close exactly that gap with a few lines of TypeScript. In this article you build a small partner API for a fictional cargo bike rental step by step: generate keys, store only the hash, check them with a Lambda authorizer, recognize the partner in the backend and deploy everything with the AWS CDK. If you prefer the Serverless Framework, there’s a matching serverless.yml as well.
In short
A Lambda authorizer is a small function that API Gateway puts in front of every protected route and that answers with “allowed” or “not allowed” plus a bit of context. For API keys on an HTTP API, it’s the cleanest option as long as you store only hashes and configure the cache on purpose. The biggest pitfall: a cached result stays valid even after you have revoked the key.
What you will learn in this article
- When a Lambda authorizer fits better than JWT or IAM authorization
- How to generate API keys safely and store only their SHA-256 hash in DynamoDB
- How to write a tested authorizer with a simple response in TypeScript
- How to read the partner context in the backend and tell plans apart
- How caching, 401, 403 and 500 work and how to find typical errors quickly
What a Lambda authorizer does for an HTTP API
A Lambda authorizer is a regular Lambda function with a special job: API Gateway invokes it before a request reaches your actual integration. The function sees headers, path, query parameters and the so-called identity source and decides whether the request may pass. The identity source is the part of the request that identifies the caller, in our case the x-api-key header.
HTTP APIs offer two payload formats. With version 2.0, your function may return a so-called simple response, meaning just isAuthorized: true or false and an optional context with values for the backend. Alternatively, the function returns an IAM policy, as you may know from the REST API. For API keys, the simple variant is almost always enough, and it’s much easier to read and test.
How a request flows through
🎬 Request flow with a Lambda authorizer – this animated diagram only runs on my site: watch it on sascha-ms.de
Two details of this flow matter. First, API Gateway itself checks whether the identity source is present in the request at all. If the header is missing, it answers with 401 right away and doesn’t even invoke your function. Second, API Gateway can cache the result per identity source. If the same key comes back within the configured time, the Lambda invocation is skipped entirely. That saves time and money, but it has a side effect we’ll get to later.
Simple response or IAM policy?
A simple response is a yes or no for all routes the authorizer is attached to. An IAM policy, on the other hand, can allow or deny per route and method, for example GET yes, POST no. That sounds more powerful, but it makes the code harder to follow. My advice: start with the simple response. If you need per-route permissions later, solve that with $context.routeKey as described further down, or switch to policies deliberately.
When does a Lambda authorizer fit, and when doesn’t it?
Before you write code, take a quick look at the alternatives. HTTP APIs offer three ways to control access, and each has its place.
| Option | Good fit for | Not ideal for |
|---|---|---|
| JWT authorizer | Apps and frontends with a login via Cognito, Entra ID, Auth0 or another OIDC provider | Partners without their own identity provider, simple machine access |
| IAM authorization (SigV4) | Calls from other AWS accounts or your own services with an IAM role | External partners without AWS access |
| Lambda authorizer | API keys, custom token formats, IP lists, rules from your own database | Cases where a JWT authorizer is enough, since it costs no Lambda invocation |
Good to know: according to the AWS comparison table, API keys, usage plans with per-client quotas and AWS WAF are only available for REST APIs. If you need exactly those features, the REST API is the more honest choice. If, on the other hand, you want a lean HTTP API and only need to check whether a known partner is calling, the Lambda authorizer is exactly right. By the way, you can play through which API type suits your project in the AWS service finder.
The example: a partner API for a cargo bike rental
Our fictional rental company has stations across a city. Partners such as hotels or cafés should be able to query the list of stations via API. Partners on the “pro” plan also see how many bikes are currently available. Every partner gets its own key, which we can revoke at any time without affecting the others.
The building blocks are manageable: a DynamoDB table PartnerKeys with the hashes, an authorizer function CheckPartnerKey, a backend function ListStations and an HTTP API with the route GET /stations. I covered the basics of the HTTP API itself, such as stage, throttling and access logs, in detail in Build an HTTP API with API Gateway and Lambda in CDK. Here we focus on protecting it.
Prerequisites
- Node.js 22 or 24 and npm
- An AWS account with credentials set up for the AWS CLI (ideally a separate development account)
- The AWS CDK, which we install as a project dependency, and a one-time
cdk bootstrapper account and region - Basic knowledge of TypeScript and Lambda; if Lambda is new to you, start with Deploy a Lambda function with CDK and TypeScript
Build a Lambda authorizer step by step
Step 1: Create the project
Create an empty folder and install the dependencies. tsx runs TypeScript directly; we need it for the tests, a small helper script and the CDK app.
mkdir cargo-bike-partner-api && cd cargo-bike-partner-api
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D aws-cdk-lib constructs aws-cdk typescript tsx esbuild @types/aws-lambda @types/node
mkdir -p bin lib src scripts test
Add the scripts to package.json and create tsconfig.json and cdk.json:
// package.json (excerpt)
{
"scripts": {
"build": "tsc --noEmit",
"test": "tsx --test test/*.test.ts",
"synth": "cdk synth",
"create-key": "tsx scripts/create-key.ts"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["bin", "lib", "src", "scripts", "test"]
}
// cdk.json
{
"app": "npx tsx bin/app.ts"
}
Step 2: Generate keys and store only the hash
The most important rule first: you never store the key itself. Only its SHA-256 hash goes into the table. If the table is shared by accident or a backup ends up in the wrong place, nobody can do anything with the hashes. For passwords, you would need a salt and a slow hash function such as Argon2. Our keys, however, consist of 24 random bytes, which is 192 bits of randomness. Something like that can neither be guessed nor looked up in a dictionary, so a fast hash is enough, and we can query DynamoDB directly by hash.
The lrv_ prefix has a practical reason: a key with a fixed prefix stands out immediately in logs, tickets or chat histories, and secret scanners can search for it specifically.
// src/key-hash.ts
import { createHash, randomBytes } from 'node:crypto';
// The prefix makes keys easy to spot in logs, tickets and secret scanners
export const KEY_PREFIX = 'lrv_';
const KEY_LENGTH = KEY_PREFIX.length + 32;
// 24 random bytes become 32 base64url characters
export function createApiKey(): string {
return KEY_PREFIX + randomBytes(24).toString('base64url');
}
// Only the SHA-256 hash goes into the table, never the key itself
export function hashApiKey(apiKey: string): string {
return createHash('sha256').update(apiKey, 'utf8').digest('hex');
}
// Cheap pre-check: obvious garbage never reaches DynamoDB
export function looksLikeApiKey(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length === KEY_LENGTH &&
value.startsWith(KEY_PREFIX) &&
/^[A-Za-z0-9_-]+$/.test(value.slice(KEY_PREFIX.length))
);
}
To register a partner, we write a small script. It shows the key exactly once and prints the matching put-item command with the hash. Hand the key to the partner through a secure channel, for example a password manager with sharing.
// scripts/create-key.ts
import { createApiKey, hashApiKey } from '../src/key-hash';
const [partnerId, plan = 'basic'] = process.argv.slice(2);
if (!partnerId || !['basic', 'pro'].includes(plan)) {
console.error('Usage: npm run create-key -- <partnerId> [basic|pro]');
process.exit(1);
}
const apiKey = createApiKey();
const item = {
keyHash: { S: hashApiKey(apiKey) },
partnerId: { S: partnerId },
plan: { S: plan },
active: { BOOL: true },
createdAt: { S: new Date().toISOString() },
};
console.log(`Key for ${partnerId} (shown only once):`);
console.log(apiKey);
console.log('\nWrite it to the table:');
console.log(`aws dynamodb put-item --table-name "$TABLE" --item '${JSON.stringify(item)}'`);
Step 3: Write the Lambda authorizer
Now for the core piece. I build the handler with a small factory function that receives the partner lookup as a parameter. In AWS, that’s a GetItem on DynamoDB; in the test, it’s a plain Map. That way you test the logic without mocking the AWS SDK.
// src/authorizer.ts
import type {
APIGatewayRequestAuthorizerEventV2,
APIGatewaySimpleAuthorizerResult,
APIGatewaySimpleAuthorizerWithContextResult,
} from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { hashApiKey, looksLikeApiKey } from './key-hash';
export type PartnerContext = { partnerId: string; plan: string };
export type PartnerRecord = {
keyHash: string;
partnerId: string;
plan: string;
active: boolean;
expiresAt?: string; // ISO date, optional
};
export type FindPartner = (keyHash: string) => Promise<PartnerRecord | undefined>;
export type AuthorizerResult =
| APIGatewaySimpleAuthorizerResult
| APIGatewaySimpleAuthorizerWithContextResult<PartnerContext>;
const deny: APIGatewaySimpleAuthorizerResult = { isAuthorized: false };
// Factory: tests pass in a fake lookup, AWS uses the DynamoDB lookup
export function createAuthorizer(findPartner: FindPartner, now: () => Date = () => new Date()) {
return async (event: APIGatewayRequestAuthorizerEventV2): Promise<AuthorizerResult> => {
// identitySource holds the values in the configured order
const apiKey = event.identitySource?.[0];
if (!looksLikeApiKey(apiKey)) return deny;
// Lookup errors are rethrown: the client gets a 500 instead of a 403
const partner = await findPartner(hashApiKey(apiKey));
if (!partner || partner.active !== true) return deny;
if (partner.expiresAt && Date.parse(partner.expiresAt) <= now().getTime()) return deny;
// Only flat, harmless values go into the context, never the key
return {
isAuthorized: true,
context: { partnerId: partner.partnerId, plan: partner.plan },
};
};
}
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const findPartnerInTable: FindPartner = async (keyHash) => {
const result = await db.send(
new GetCommand({ TableName: process.env.PARTNER_KEYS_TABLE, Key: { keyHash } }),
);
return result.Item as PartnerRecord | undefined;
};
export const handler = createAuthorizer(findPartnerInTable);
A few decisions in this code deserve an explanation:
- I read the key from
event.identitySource[0]instead of the headers. It holds exactly the value API Gateway determined as the identity source, no matter how the client spelled the header. - The pre-check with
looksLikeApiKeyfilters out obvious garbage before a database query happens. That also protects you a little against load spikes from random requests. - I deliberately don’t catch lookup errors. If the function throws, API Gateway answers with 500. That tells the partner the problem is on your side, not with their key. A silent
isAuthorized: falsewould send them down the wrong path. - The context only gets flat, harmless values such as partner ID and plan. The key or its hash has no business there, because the context can end up in logs.
Step 4: Read the context in the backend
Whatever the authorizer returns in context arrives in the backend function under event.requestContext.authorizer.lambda. The types from @types/aws-lambda model this neatly with APIGatewayProxyEventV2WithLambdaAuthorizer.
// src/stations.ts
import type {
APIGatewayProxyEventV2WithLambdaAuthorizer,
APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';
import type { PartnerContext } from './authorizer';
const STATIONS = [
{ id: 'central', name: 'Central Station', freeBikes: 4 },
{ id: 'harbour', name: 'Harbour', freeBikes: 2 },
{ id: 'market', name: 'Market Square', freeBikes: 0 },
];
export const handler = async (
event: APIGatewayProxyEventV2WithLambdaAuthorizer<PartnerContext>,
): Promise<APIGatewayProxyStructuredResultV2> => {
// The authorizer context arrives here: requestContext.authorizer.lambda
const { partnerId, plan } = event.requestContext.authorizer.lambda;
// Plan "basic" only sees the stations, "pro" also sees free bikes
const stations = plan === 'pro' ? STATIONS : STATIONS.map(({ id, name }) => ({ id, name }));
console.log(JSON.stringify({ msg: 'stations listed', partnerId, plan, count: stations.length }));
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ partnerId, stations }),
};
};
So the backend no longer has to deal with keys. It knows which partner is calling and can decide what that partner may see. This separation is what makes the approach pleasant: the check lives in one place, and every route benefits from it.
Step 5: Write and run the tests
The tests use the built-in Node.js test runner. They cover the success case, missing and malformed keys, deactivated and expired partners, error propagation and the backend behavior.
// test/authorizer.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { APIGatewayRequestAuthorizerEventV2 } from 'aws-lambda';
import { createAuthorizer, type PartnerRecord } from '../src/authorizer';
import { createApiKey, hashApiKey, looksLikeApiKey } from '../src/key-hash';
import { handler as listStations } from '../src/stations';
const validKey = createApiKey();
const records = new Map<string, PartnerRecord>([
[hashApiKey(validKey), { keyHash: hashApiKey(validKey), partnerId: 'lakeside-hotel', plan: 'pro', active: true }],
]);
let lookups = 0;
const findPartner = async (keyHash: string) => {
lookups++;
return records.get(keyHash);
};
const fixedNow = () => new Date('2026-10-28T12:00:00Z');
const authorize = createAuthorizer(findPartner, fixedNow);
function eventWithKey(key?: string): APIGatewayRequestAuthorizerEventV2 {
return {
version: '2.0',
type: 'REQUEST',
routeArn: 'arn:aws:execute-api:eu-central-1:123456789012:abc123/$default/GET/stations',
identitySource: key === undefined ? [] : [key],
routeKey: 'GET /stations',
rawPath: '/stations',
rawQueryString: '',
headers: key === undefined ? {} : { 'x-api-key': key },
requestContext: { requestId: 'test-request' },
} as unknown as APIGatewayRequestAuthorizerEventV2;
}
test('new keys have the expected format', () => {
const key = createApiKey();
assert.equal(key.length, 36);
assert.ok(looksLikeApiKey(key));
assert.equal(hashApiKey(key).length, 64);
});
test('allows a valid key and passes the partner context', async () => {
const result = await authorize(eventWithKey(validKey));
assert.deepEqual(result, { isAuthorized: true, context: { partnerId: 'lakeside-hotel', plan: 'pro' } });
});
test('denies a missing key without a lookup', async () => {
lookups = 0;
assert.deepEqual(await authorize(eventWithKey()), { isAuthorized: false });
assert.equal(lookups, 0);
});
test('denies malformed keys without a lookup', async () => {
lookups = 0;
for (const key of ['secret', 'lrv_' + 'x'.repeat(10), 'abc_' + 'x'.repeat(32), 'lrv_' + '!'.repeat(32)]) {
assert.deepEqual(await authorize(eventWithKey(key)), { isAuthorized: false });
}
assert.equal(lookups, 0);
});
test('denies an unknown key', async () => {
assert.deepEqual(await authorize(eventWithKey(createApiKey())), { isAuthorized: false });
});
test('denies deactivated and expired keys', async () => {
const inactive = createApiKey();
const expired = createApiKey();
records.set(hashApiKey(inactive), { keyHash: hashApiKey(inactive), partnerId: 'alt', plan: 'basic', active: false });
records.set(hashApiKey(expired), {
keyHash: hashApiKey(expired), partnerId: 'pilot', plan: 'basic', active: true, expiresAt: '2026-10-01T00:00:00Z',
});
assert.deepEqual(await authorize(eventWithKey(inactive)), { isAuthorized: false });
assert.deepEqual(await authorize(eventWithKey(expired)), { isAuthorized: false });
});
test('propagates lookup errors: 500 instead of 403', async () => {
const broken = createAuthorizer(async () => { throw new Error('DynamoDB not reachable'); });
await assert.rejects(() => broken(eventWithKey(validKey)), /DynamoDB not reachable/);
});
test('backend reads the context and hides free bikes for basic', async () => {
const event = (plan: string) =>
({ requestContext: { authorizer: { lambda: { partnerId: 'p1', plan } } } }) as never;
const basic = JSON.parse((await listStations(event('basic'))).body as string);
const pro = JSON.parse((await listStations(event('pro'))).body as string);
assert.equal(basic.stations[0].freeBikes, undefined);
assert.equal(pro.stations[0].freeBikes, 4);
});
npm run build
npm test
Expected result (shortened):
ok 1 - new keys have the expected format
ok 2 - allows a valid key and passes the partner context
...
ok 8 - backend reads the context and hides free bikes for basic
# tests 8
# pass 8
# fail 0
Step 6: Describe the stack with the AWS CDK
The stack ties everything together. The key part is HttpLambdaAuthorizer from aws-cdk-lib/aws-apigatewayv2-authorizers. Watch out for two CDK defaults: unless told otherwise, it expects an IAM policy as the response and reads the Authorization header. That’s why we change both explicitly. The CDK creates the permission for API Gateway to invoke the authorizer function automatically.
// lib/partner-api-stack.ts
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import { AccessLogFormat } from 'aws-cdk-lib/aws-apigateway';
import { HttpApi, HttpMethod, LogGroupLogDestination } from 'aws-cdk-lib/aws-apigatewayv2';
import {
HttpLambdaAuthorizer,
HttpLambdaResponseType,
} from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { AttributeType, TableV2 } from 'aws-cdk-lib/aws-dynamodb';
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
export class PartnerApiStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Table with the hashed partner keys (on-demand is the TableV2 default)
const keys = new TableV2(this, 'PartnerKeys', {
partitionKey: { name: 'keyHash', type: AttributeType.STRING },
removalPolicy: RemovalPolicy.DESTROY, // fine for the example; use RETAIN in production
});
const logs = (name: string) =>
new LogGroup(this, name, {
retention: RetentionDays.ONE_WEEK,
removalPolicy: RemovalPolicy.DESTROY,
});
const checkPartnerKey = new NodejsFunction(this, 'CheckPartnerKey', {
entry: 'src/authorizer.ts',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
memorySize: 256,
timeout: Duration.seconds(3), // an authorizer has to be fast
logGroup: logs('CheckPartnerKeyLogs'),
environment: { PARTNER_KEYS_TABLE: keys.tableName },
bundling: { minify: true },
});
keys.grantReadData(checkPartnerKey);
const listStations = new NodejsFunction(this, 'ListStations', {
entry: 'src/stations.ts',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
memorySize: 256,
timeout: Duration.seconds(5),
logGroup: logs('ListStationsLogs'),
bundling: { minify: true },
});
// The Lambda authorizer: simple response, key from the header, 5-minute cache
const partnerKeyAuthorizer = new HttpLambdaAuthorizer('PartnerKeyAuthorizer', checkPartnerKey, {
responseTypes: [HttpLambdaResponseType.SIMPLE],
identitySource: ['$request.header.x-api-key'],
resultsCacheTtl: Duration.minutes(5),
});
const api = new HttpApi(this, 'PartnerApi', {
apiName: 'cargo-bike-partner-api',
createDefaultStage: false,
});
const stage = api.addStage('DefaultStage', {
stageName: '$default',
autoDeploy: true,
throttle: { rateLimit: 20, burstLimit: 40 },
accessLogSettings: {
destination: new LogGroupLogDestination(logs('PartnerApiAccessLogs')),
format: AccessLogFormat.custom(
JSON.stringify({
requestId: '$context.requestId',
route: '$context.routeKey',
status: '$context.status',
partnerId: '$context.authorizer.partnerId',
authorizerError: '$context.authorizer.error',
latencyMs: '$context.responseLatency',
}),
),
},
});
api.addRoutes({
path: '/stations',
methods: [HttpMethod.GET],
integration: new HttpLambdaIntegration('ListStationsIntegration', listStations),
authorizer: partnerKeyAuthorizer,
});
new CfnOutput(this, 'ApiUrl', { value: stage.url });
new CfnOutput(this, 'PartnerKeysTable', { value: keys.tableName });
}
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { PartnerApiStack } from '../lib/partner-api-stack';
const app = new App();
new PartnerApiStack(app, 'CargoBikePartnerApi', {
env: { region: 'eu-central-1' },
});
Thanks to $context.authorizer.partnerId and $context.authorizer.error, the access log records the recognized partner and any authorizer errors. That’s worth its weight in gold when debugging, more on that shortly.
Step 7: Deploy and create the first key
npx cdk bootstrap # only the first time per account and region
npx cdk deploy
After the deployment, read the stack outputs into two variables and generate a key for a partner:
export AWS_REGION=eu-central-1
export API_URL=$(aws cloudformation describe-stacks --stack-name CargoBikePartnerApi \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text)
export TABLE=$(aws cloudformation describe-stacks --stack-name CargoBikePartnerApi \
--query "Stacks[0].Outputs[?OutputKey=='PartnerKeysTable'].OutputValue" --output text)
npm run create-key -- lakeside-hotel pro
The script shows the key and a ready-to-run aws dynamodb put-item command. Run that command, and the partner is registered. Keep the key as KEY for the next step.
Step 8: Test the API with curl
# without a key
curl -i "${API_URL}stations"
# with a wrong key
curl -i -H "x-api-key: lrv_abcdefghijklmnopqrstuvwxyz012345" "${API_URL}stations"
# with a valid key
curl -i -H "x-api-key: $KEY" "${API_URL}stations"
This is what the responses look like (example output, shortened):
HTTP/2 401
{"message":"Unauthorized"}
HTTP/2 403
{"message":"Forbidden"}
HTTP/2 200
{"partnerId":"lakeside-hotel","stations":[{"id":"central","name":"Central Station","freeBikes":4}, ...]}
Three status codes, three meanings: 401 means “no key sent”, 403 means “key checked and rejected”, 200 means “let through”. The following chart sums up which response appears when.
Which response appears when: missing header, cache, authorizer error, denial or success.
The same with the Serverless Framework
If you prefer the Serverless Framework, the TypeScript files stay the same. Version 4 of the framework bundles them with esbuild on its own. The authorizer configuration lives under provider.httpApi.authorizers, and the route references it by name.
# serverless.yml
service: cargo-bike-partner-api
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs24.x
architecture: arm64
region: eu-central-1
httpApi:
authorizers:
partnerKey:
type: request
functionName: checkPartnerKey
identitySource:
- $request.header.x-api-key
resultTtlInSeconds: 300
enableSimpleResponses: true
payloadVersion: '2.0'
iam:
role:
statements:
- Effect: Allow
Action: dynamodb:GetItem
Resource: !GetAtt PartnerKeysTable.Arn
functions:
checkPartnerKey:
handler: src/authorizer.handler
timeout: 3
environment:
PARTNER_KEYS_TABLE: !Ref PartnerKeysTable
listStations:
handler: src/stations.handler
events:
- httpApi:
method: GET
path: /stations
authorizer:
name: partnerKey
resources:
Resources:
PartnerKeysTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: keyHash
AttributeType: S
KeySchema:
- AttributeName: keyHash
KeyType: HASH
npx serverless deploy
There are two differences from the CDK you should know about: enableSimpleResponses defaults to false in the framework, so make sure to set it. And you specify the cache duration in seconds. Allowed values range from 0 to 3600, where 0 turns the cache off.
Configure caching for your Lambda authorizer
Without a cache, every request triggers a Lambda invocation and a DynamoDB query. That costs a few milliseconds and, with a lot of traffic, noticeable money. With the cache, API Gateway remembers the decision per identity source, in other words per key. The CDK uses five minutes by default, and the maximum is one hour. For the cache to work, the authorizer needs at least one identity source.
The catch: revoking only takes effect after the TTL
When you set a key to active: false, the cached permission remains valid until the TTL expires. With five minutes, that’s acceptable for most partner APIs. With one hour and a leaked key, probably not. So think in advance about how quickly a revocation has to take effect, and pick the TTL accordingly. In an emergency, set the TTL to 0 and redeploy. Even then, expect results that are already cached to stay valid until their time is up.
Per-route permissions with $context.routeKey
A simple response applies to all routes the authorizer is attached to, and it’s cached that way, too. Suppose partners on the “basic” plan may later call GET /stations but not POST /bookings. In that case, add $context.routeKey as a second identity source. Each route then gets its own cache entry, and in the authorizer you additionally check event.routeKey. Since we read the key via identitySource[0], the header has to stay first in the list.
Common Lambda authorizer errors
Every request ends with 401 even though the header is set
Usually the identity source name doesn’t match the header the client sends. Check the spelling in $request.header.x-api-key and use curl -v to see what actually goes out. An empty header also counts as missing.
The API answers with 500 Internal Server Error
Here, a 500 means API Gateway couldn’t invoke the authorizer, or the response had the wrong format. Typical causes are a missing invoke permission (rare with the CDK, common with hand-written CloudFormation), a simple response while enableSimpleResponses is off, or an exception in your function, for example because the table permission is missing. The fastest way to the cause is the authorizerError field in the access log, followed by the authorizer function’s log.
A revoked partner still gets through
That’s the cache. As described above, the revocation takes effect at the latest when the TTL expires. If that’s too long, shorten it.
The context is empty in the backend
With payload format 2.0, the context sits under requestContext.authorizer.lambda, not directly under requestContext.authorizer. People who copy code from a REST API often trip over this. Also check that the route really uses the authorizer and wasn’t accidentally created without protection.
The browser reports CORS errors
If CORS is configured on the HTTP API, API Gateway answers preflight requests itself, without the authorizer. Problems mostly arise with catch-all routes such as ANY /{proxy+} that have an authorizer. For pure partner APIs, CORS is often not needed at all, because a server calls them, not a browser. An API key never belongs in frontend code anyway.
Keep an eye on cost and security
An authorizer invocation is a normal Lambda invocation and is billed that way, plus a GetItem on DynamoDB. For a partner API with moderate traffic, that hardly matters, especially since the cache absorbs most calls. If you want to know exactly for your load, calculate authorizer and backend separately in the Lambda cost calculator.
For security, I’ve settled on a few rules. Never log keys, not even truncated. Give every partner their own keys so you can revoke them individually. To rotate, create a second key, let the partner switch and then deactivate the old one. And because HTTP APIs have no per-client quotas, the stage throttling sets the upper limit for everyone combined. If you need hard limits per partner, that’s a strong argument for the REST API with usage plans.
My take
I like this approach because there’s so little magic in it. One table, one function with 40 lines, a few tests, and you always know who uses your API. Especially for partner interfaces, that’s often more honest than a big identity setup that ends up managing one technical user per partner.
At the same time, I don’t think API keys are the answer to everything. As soon as real people sign in or partners need fine-grained permissions, a JWT authorizer with a proper identity provider is the better choice. And if you need quotas, per-client billing or WAF, don’t get fancy with a home-grown solution; use the REST API. The Lambda authorizer is the right tool for the middle ground: simple machine access you want to keep under your own control.
One point where I disagree with many examples online: a plain-text key in an environment variable or table is not a case of “good enough for now”. Storing the hash costs one more line of code and spares you a very awkward email to all your partners when things go wrong.
FAQ
Does the API Gateway HTTP API support API keys?
No. API keys and usage plans exist only for REST APIs. With an HTTP API, you build the key check yourself with a Lambda authorizer, as shown in this article.
How long can API Gateway cache a Lambda authorizer result?
Between 0 and 3600 seconds. With 0, the cache is off. Without an explicit value, the CDK uses five minutes. The authorizer needs at least one identity source for caching.
What is the difference between 401 and 403 with a Lambda authorizer?
401 comes back when the identity source is missing from the request. The authorizer isn’t invoked at all in that case. 403 comes back when the authorizer ran and returned isAuthorized: false.
Lambda authorizer or JWT authorizer, which one should I use?
If you have an identity provider that issues JWTs, use the JWT authorizer. It validates tokens without your own code and without Lambda cost. You need the Lambda authorizer for custom rules such as API keys, your own token formats or checks against a database.
Whether an HTTP API with a Lambda authorizer is enough for your interface or a REST API with usage plans is worth it is covered in my decision guide REST API vs HTTP API: Choosing API Gateway.
Your next step
Add the route POST /bookings and allow it only for partners on the “pro” plan. To do that, add $context.routeKey as a second identity source, check event.routeKey in the authorizer and first write the test that rejects a “basic” partner with 403. If you still feel like it afterwards, use the pattern from Idempotency in AWS Lambda to make sure bookings sent twice don’t count twice.
I’m curious: how do you protect machine access to your APIs, with API keys, OAuth client credentials or something else entirely? Let me know in the comments.
Sources
- Amazon API Gateway Developer Guide: Control access to HTTP APIs with AWS Lambda authorizers
- Amazon API Gateway V2 API Reference: Authorizers (authorizerResultTtlInSeconds, enableSimpleResponses, identitySource)
- API Gateway: Choose between REST APIs and HTTP APIs
- API Gateway: Customize HTTP API access logs ($context.authorizer.*)
- AWS Compute Blog: Introducing IAM and Lambda authorizers for Amazon API Gateway HTTP APIs
- AWS CDK API Reference: HttpLambdaAuthorizer
- Serverless Framework Docs: HTTP API (Lambda request authorizers)
Tested with: Node.js 22.23, TypeScript 7.0.2, tsx, aws-cdk-lib 2.275.0, CDK CLI 2.1146.1, @aws-sdk/lib-dynamodb 3.1158.0 (tsc, 8 tests with node:test, cdk synth, esbuild bundle invoked locally); serverless.yml only validated as YAML; deploy and curl output are marked as examples.
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)