Every tutorial in this series so far assumes the endpoint is open. In production it almost never is. This post covers the three ways to put authentication and authorization in front of a Serverless Framework V4 API on AWS — the built-in JWT authorizer, a custom Lambda authorizer, and IAM/SigV4 auth for service-to-service calls — and, just as importantly, when to pick each one.
Stack: Serverless Framework V4, Node.js 22, API Gateway HTTP API (httpApi events), Amazon Cognito, and the AWS SDK v3.
Which authorizer do you actually need?
| Situation | Use |
|---|---|
| End users signing in; you own the user directory | Cognito user pool + JWT authorizer |
| Tokens from Auth0, Okta, Entra ID, or any OIDC provider | JWT authorizer pointed at that issuer |
| API keys, HMAC signatures, per-tenant lookups, opaque tokens | Lambda (request) authorizer |
| Another AWS service or an internal account calling you | IAM authorizer (SigV4) |
| Public endpoint with abuse concerns only | No authorizer; rate limits + WAF |
The JWT authorizer is free, runs inside API Gateway, and adds no latency you pay for. Reach for a Lambda authorizer only when the JWT authorizer genuinely cannot express your rule — its results are cacheable, but every cache miss is an extra Lambda invocation on your critical path.
1. A Cognito user pool in serverless.yml
Define the pool and client as CloudFormation resources so they live and version with the service. For a real product, put the pool in its own stack: deleting an app stack that owns your user directory is a very bad afternoon.
resources:
Resources:
UserPool:
Type: AWS::Cognito::UserPool
DeletionPolicy: Retain
Properties:
UserPoolName: ${self:service}-${sls:stage}
AutoVerifiedAttributes: [email]
UsernameAttributes: [email]
Policies:
PasswordPolicy:
MinimumLength: 12
RequireNumbers: true
RequireSymbols: true
RequireUppercase: true
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: web
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
AccessTokenValidity: 60
IdTokenValidity: 60
TokenValidityUnits:
AccessToken: minutes
IdToken: minutes
AdminGroup:
Type: AWS::Cognito::UserPoolGroup
Properties:
GroupName: admin
UserPoolId: !Ref UserPool
DeletionPolicy: Retain on the pool is not optional. Note also the short token lifetimes: a leaked access token is valid until it expires, and API Gateway has no revocation list.
2. Wire up the JWT authorizer
HTTP APIs support JWT authorizers natively. Declare it once under provider.httpApi.authorizers and reference it by name on each function.
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
httpApi:
cors: true
authorizers:
cognitoJwt:
type: jwt
identitySource: $request.header.Authorization
issuerUrl:
Fn::Join:
- ''
- - 'https://cognito-idp.'
- ${aws:region}
- '.amazonaws.com/'
- !Ref UserPool
audience:
- !Ref UserPoolClient
functions:
listOrders:
handler: src/orders.list
events:
- httpApi:
path: /orders
method: get
authorizer:
name: cognitoJwt
health:
handler: src/health.handler
events:
- httpApi: # deliberately public
path: /health
method: get
API Gateway now validates the signature, exp, iss and aud for you and rejects anything else with a 401 before your function is invoked. You never pay for an unauthenticated request.
For a third-party IdP, replace the issuer with, say, https://your-tenant.eu.auth0.com/ and the audience with your API identifier. Nothing else changes.
Scopes. If your tokens carry OAuth scopes, enforce them at the route:
authorizer:
name: cognitoJwt
scopes:
- orders/write
3. Read the claims in your handler
The verified claims arrive on the request context. Trust these; never decode the raw header yourself.
import type { APIGatewayProxyEventV2WithJWTAuthorizer } from 'aws-lambda';
type Claims = { sub: string; email?: string; 'cognito:groups'?: string | string[] };
export const list = async (event: APIGatewayProxyEventV2WithJWTAuthorizer) => {
const claims = event.requestContext.authorizer.jwt.claims as unknown as Claims;
const userId = claims.sub;
const raw = claims['cognito:groups'];
const groups = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[\s,]+/) : [];
// Authorization is your job: the authorizer only proved *who* they are.
const orders = await repo.findByOwner(groups.includes('admin') ? undefined : userId);
return { statusCode: 200, body: JSON.stringify({ orders }) };
};
Two traps worth naming:
- Authentication is not authorization. A valid token for user A must not let them read user B's rows. Scope every query by
sub(or tenant id) at the data layer, not with anifnear the top of the handler. cognito:groupsarrives as an array in the ID token and, in some paths, as a space-separated string. Normalize it, as above, rather than assuming.
4. A Lambda request authorizer for the cases JWT cannot cover
Opaque API keys, HMAC-signed partner requests, or per-tenant rules need code. Use a request authorizer with simple responses:
authorizers:
apiKeyAuth:
type: request
functionName: authorizer
identitySource:
- $request.header.x-api-key
resultTtlInSeconds: 300
enableSimpleResponses: true
export const handler = async (event: { headers: Record<string, string> }) => {
const key = event.headers['x-api-key'];
if (!key) return { isAuthorized: false };
// Hash before lookup; never store or log raw keys.
const hash = createHash('sha256').update(key).digest('hex');
const record = await keys.get(hash);
if (!record || record.revokedAt) return { isAuthorized: false };
return {
isAuthorized: true,
context: { tenantId: record.tenantId, plan: record.plan },
};
};
Read it downstream from event.requestContext.authorizer.lambda.tenantId.
About resultTtlInSeconds: caching is what keeps this pattern cheap, but the cache key is the identity source, so a revoked key stays valid for up to the TTL. Five minutes is a reasonable compromise; if you need instant revocation, set it to 0 and accept the invocation cost. And keep the authorizer function tiny — it is on the latency path of every request. Give it 512 MB, bundle it with esbuild, and keep its DynamoDB lookup single-key.
5. IAM auth for service-to-service calls
When the caller is another AWS principal, skip tokens entirely:
- httpApi:
path: /internal/reindex
method: post
authorizer:
type: aws_iam
The caller signs the request with SigV4 and you grant execute-api:Invoke on that route to its role. No secrets to rotate, no tokens to leak, and the identity shows up in CloudTrail.
6. Test it
# Create a confirmed test user
aws cognito-idp admin-create-user --user-pool-id "$POOL" --username dev@example.com \
--message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL" --username dev@example.com \
--password 'Str0ng-Passw0rd!' --permanent
# Get an access token
TOKEN=$(aws cognito-idp admin-initiate-auth --user-pool-id "$POOL" --client-id "$CLIENT" \
--auth-flow ADMIN_USER_PASSWORD_AUTH \
--auth-parameters USERNAME=dev@example.com,PASSWORD='Str0ng-Passw0rd!' \
--query 'AuthenticationResult.AccessToken' --output text)
curl -i "$API/orders" # expect 401
curl -i -H "Authorization: Bearer $TOKEN" "$API/orders" # expect 200
Add both cases to your integration suite. The test that a protected route returns 401 without a token is the one that catches the day someone copies a function block and forgets the authorizer key.
Checklist before you ship
- Every route has an authorizer, or a comment explaining why it does not.
- Access tokens live an hour or less; refresh tokens are handled client-side.
- The user pool has
DeletionPolicy: Retainand, ideally, its own stack. - Data queries are scoped by
sub/tenant, not just gated at the handler. - API keys are stored hashed; the authorizer never logs the raw value.
- 401 and 403 paths have their own alarms — a spike is a probe.
- WAF or throttling protects public routes and the token endpoint.
Getting this wrong is the most common finding in the audits we run. Our Serverless Security Consulting practice reviews API authorization, IAM boundaries and secrets handling across your services; get in touch if you would like a second pair of eyes before launch.