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 bootstrap per 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 looksLikeApiKey filters 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: false would 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'Source:https://dev.to/sascha-ms/lambda-authorizer-for-http-apis-validate-api-keys-3laa

Related posts