DEV Community

Cover image for Lambda authorizer for HTTP APIs: validate API keys
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Lambda authorizer for HTTP APIs: validate API keys

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["bin", "lib", "src", "scripts", "test"]
}
Enter fullscreen mode Exit fullscreen mode
// cdk.json
{
  "app": "npx tsx bin/app.ts"
}
Enter fullscreen mode Exit fullscreen mode

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))
  );
}
Enter fullscreen mode Exit fullscreen mode

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)}'`);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 }),
  };
};
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode
npm run build
npm test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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' },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}, ...]}
Enter fullscreen mode Exit fullscreen mode

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.

Flowchart: when a Lambda authorizer returns 401, 403, 500 or 200 and when the result comes from the cache

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
Enter fullscreen mode Exit fullscreen mode
npx serverless deploy
Enter fullscreen mode Exit fullscreen mode

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

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)