+1 (726) 207-9872

Securing Your Serverless API: Cognito JWT, Lambda Authorizers, and IAM Auth with Serverless Framework V4

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?

SituationUse
End users signing in; you own the user directoryCognito user pool + JWT authorizer
Tokens from Auth0, Okta, Entra ID, or any OIDC providerJWT authorizer pointed at that issuer
API keys, HMAC signatures, per-tenant lookups, opaque tokensLambda (request) authorizer
Another AWS service or an internal account calling youIAM authorizer (SigV4)
Public endpoint with abuse concerns onlyNo 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 an if near the top of the handler.
  • cognito:groups arrives 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: Retain and, 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.