Skip to main content

REST API with Cognito authorizer

The rest-api-cognito-authorizer project is a self-contained AWS CDK application that uses Cognito as the default authorizer for a REST API. It demonstrates a protected route through inheritance, a public route through an explicit override, and two independent Lambda functions grouped in one source module.

Architecture​

The stack contains an API Gateway REST API, a Cognito User Pool, a User Pool Client, a Cognito User Pools Authorizer, and two independent Lambda functions. The route-level authorization is:

MethodPathFunctionEntrypointEffective access
GET/healthhealthauth.healthPublic through @public
GET/memeauth.meCognito inherited from the default

/health reaches its Lambda directly because the explicit @public override removes the default authorizer for that route. /me first passes through the Cognito User Pools Authorizer. API Gateway rejects requests without a valid token before invoking auth.me.

The defensive 401 in auth.me covers direct invocations or malformed events without claims; it does not replace API Gateway authentication.

Resources created​

The application creates:

  • one API Gateway REST API;
  • one Cognito User Pool with username sign-in;
  • one Cognito User Pool Client without a generated secret, with USER_PASSWORD_AUTH enabled;
  • one Cognito User Pools Authorizer connected to the pool;
  • one Lambda for auth.health;
  • one Lambda for auth.me.

Self sign-up is disabled, so users are administered with the Cognito CLI after deployment. CloudFormation does not create users, and the application does not store passwords or tokens. The User Pool uses RemovalPolicy.DESTROY; destroying the stack therefore removes the pool and all users stored in it.

Project structure​

This is the complete project tree relevant to the example:

rest-api-cognito-authorizer/
├── app.py
├── cdk.json
├── requirements.txt
├── requirements-dev.txt
├── README.md
├── lambdas/
│ ├── __init__.py
│ ├── auth.py
│ └── requirements.txt
├── rest_api_cognito_authorizer/
│ ├── __init__.py
│ └── rest_api_cognito_authorizer_stack.py
└── tests/
├── test_handlers.py
└── test_stack.py

lambdas/__init__.py makes the directory a package during the test suite and avoids import collisions between examples. It does not add runtime behavior or infrastructure resources.

Stack​

The complete rest_api_cognito_authorizer/rest_api_cognito_authorizer_stack.py is:

from aws_cdk import CfnOutput, RemovalPolicy, Stack
from aws_cdk import aws_apigateway as apigateway
from aws_cdk import aws_cognito as cognito
from constructs import Construct
from lambda_api_decorators_cdk import LambdaApi, LambdaApiConfig


class RestApiCognitoAuthorizerStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)

user_pool = cognito.UserPool(
self,
"UserPool",
sign_in_aliases=cognito.SignInAliases(username=True),
self_sign_up_enabled=False,
password_policy=cognito.PasswordPolicy(
min_length=8,
require_lowercase=True,
require_uppercase=True,
require_digits=True,
require_symbols=False,
),
removal_policy=RemovalPolicy.DESTROY,
)
user_pool_client = user_pool.add_client(
"UserPoolClient",
auth_flows=cognito.AuthFlow(user_password=True),
generate_secret=False,
)
rest_api = apigateway.RestApi(self, "RestApi")
cognito_authorizer = apigateway.CognitoUserPoolsAuthorizer(
self,
"CognitoAuthorizer",
cognito_user_pools=[user_pool],
)

config = LambdaApiConfig(
default_runtime="python3.14",
authorizer_registry={
"cognito": cognito_authorizer,
},
default_authorizer="cognito",
)
api = LambdaApi(
self,
"Api",
lambda_path="lambdas",
api=rest_api,
config=config,
)

CfnOutput(self, "ApiUrl", value=api.api.url)
CfnOutput(self, "UserPoolId", value=user_pool.user_pool_id)
CfnOutput(self, "UserPoolClientId", value=user_pool_client.user_pool_client_id)

The client enables USER_PASSWORD_AUTH and sets generate_secret=False. The pool is administrative because self_sign_up_enabled=False. No user, password, token, or credential value is placed in the CloudFormation template.

Lambda handlers​

Both routes are declared in one source module. The complete lambdas/auth.py is:

import json

from lambda_api_decorators import GET, public


JSON_HEADERS = {"Content-Type": "application/json"}


@GET("/health")
@public
def health(event, context):
return {
"statusCode": 200,
"headers": JSON_HEADERS,
"body": json.dumps({"status": "ok"}),
}


@GET("/me")
def me(event, context):
claims = (
event.get("requestContext", {})
.get("authorizer", {})
.get("claims")
)
if not isinstance(claims, dict) or not claims.get("sub"):
return {
"statusCode": 401,
"headers": JSON_HEADERS,
"body": json.dumps({"message": "Unauthorized"}),
}

body = {"sub": claims["sub"]}
if claims.get("cognito:username"):
body["username"] = claims["cognito:username"]
return {
"statusCode": 200,
"headers": JSON_HEADERS,
"body": json.dumps(body),
}

health returns a proxy response for the public route. me extracts REST API claims, requires sub, and optionally returns cognito:username; it does not return tokens or call AWS services. The two functions share a file, but CDK creates two independent Lambda resources and each function declares exactly one route.

Authorization model​

authorizer_registry​

authorizer_registry associates the logical key cognito with the actual CDK CognitoUserPoolsAuthorizer object. Registering an authorizer does not apply it by itself.

default_authorizer​

default_authorizer="cognito" selects Cognito for routes that do not declare an override. /me inherits this setting and therefore does not need to repeat @authorizer("cognito").

@public​

/health uses the argument-free decorator:

@public

That explicit override replaces the default authorizer for this function. Each handler declares one route, both handlers share lambdas/auth.py, and CDK still creates two independent Lambdas. The authorizer protects an API Gateway method, not the complete Python file.

Override diagnostic​

Cognito is the default, and /health is the explicit deviation. The consolidated diagnostic reports GET /health as PUBLIC; it does not emit LAD_AUTH_PUBLIC_DEFAULT. /me is absent from the override report because it inherits the default.

Outputs​

The stack creates these exact outputs:

OutputMeaning
ApiUrlBase URL of the REST API
UserPoolIdCognito User Pool identifier
UserPoolClientIdCognito User Pool Client identifier

There are no outputs for passwords, tokens, or secrets.

Tests​

The handler tests verify:

  • the /health response;
  • valid claims in /me;
  • an optional username;
  • no tokens in the response;
  • a defensive 401 without claims;
  • no AWS calls;
  • two functions in one module;
  • one route per function.

The infrastructure tests verify:

  • the User Pool;
  • a User Pool Client without a generated secret;
  • deletion policies;
  • the Cognito authorizer;
  • the REST API;
  • two Lambdas;
  • two entrypoints;
  • public /health;
  • protected /me;
  • the default and explicit override;
  • outputs without credentials;
  • the consolidated diagnostic.

The integrated CI workflow runs all 20 tests without skips and then runs cdk synth --quiet. It also verifies the published CDK integration version.

Prerequisites​

  • Python 3.14;
  • Node.js 22;
  • the AWS CDK CLI;
  • Docker;
  • the AWS CLI;
  • AWS credentials;
  • a configured AWS Region.

Docker must be running because PythonFunction bundles the Lambda source during infrastructure tests and synthesis. AWS credentials are needed for bootstrap, deployment, Cognito CLI commands, and cleanup.

Installation​

Use the tabs for your shell. Run these commands from the examples repository after cloning it.

cd rest-api-cognito-authorizer
python3.14 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt

The project pins lambda-api-decorators==0.3.2 for the Lambda package and lambda-api-decorators-cdk==0.4.5 for the CDK integration. The remaining dependencies come from requirements.txt, requirements-dev.txt, and lambdas/requirements.txt in the integrated example.

Tests and synthesis​

Run these commands from rest-api-cognito-authorizer:

pytest
cdk synth --quiet

Bootstrap and deploy​

From the same directory, bootstrap the account and Region selected by your AWS configuration, then deploy:

cdk bootstrap
cdk deploy

Keep the deployment outputs, or retrieve them later with the commands in the next section. Do not assume a particular account or Region.

Create a Cognito user​

First obtain the UserPoolId and UserPoolClientId outputs, choose a username and password, create the administrative user without an invitation, and make its password permanent. The client uses USER_PASSWORD_AUTH. Never store credentials in files.

STACK_NAME=RestApiCognitoAuthorizerStack
USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue' --output text)
CLIENT_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue' --output text)
API_URL=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' --output text)
read -r USERNAME
read -r -s PASSWORD
printf '\n'
aws cognito-idp admin-create-user --user-pool-id "$USER_POOL_ID" --username "$USERNAME" --temporary-password "$PASSWORD" --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$USER_POOL_ID" --username "$USERNAME" --password "$PASSWORD" --permanent
export USER_POOL_ID CLIENT_ID API_URL USERNAME PASSWORD

Read the username and password interactively. The password is not echoed by read -s.

Obtain an ID token​

Cognito returns an ID token, an access token, and a refresh token. This example extracts AuthenticationResult.IdToken and sends that ID token in the Authorization header. It does not use a Bearer prefix. Do not display or commit a real token.

export ID_TOKEN=$(aws cognito-idp initiate-auth --client-id "$CLIENT_ID" --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME="$USERNAME",PASSWORD="$PASSWORD" --query 'AuthenticationResult.IdToken' --output text)

Invoke the endpoints​

The following requests use the deployed ApiUrl output. /health is public. /me without a token is rejected by API Gateway, while /me with the ID token reaches auth.me and returns the user identifiers.

curl --fail "$API_URL/health"
curl -i "$API_URL/me"
curl --fail -H "Authorization: $ID_TOKEN" "$API_URL/me"

The expected public response is:

{"status": "ok"}

The protected response has this shape, with values supplied by Cognito:

{"sub": "user-id", "username": "alice"}

Cleanup​

cdk destroy
danger

Destroying the stack permanently deletes the Cognito User Pool and all users stored in it.