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:
| Method | Path | Function | Entrypoint | Effective access |
|---|---|---|---|---|
| GET | /health | health | auth.health | Public through @public |
| GET | /me | me | auth.me | Cognito 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_AUTHenabled; - 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:
| Output | Meaning |
|---|---|
ApiUrl | Base URL of the REST API |
UserPoolId | Cognito User Pool identifier |
UserPoolClientId | Cognito User Pool Client identifier |
There are no outputs for passwords, tokens, or secrets.
Tests
The handler tests verify:
- the
/healthresponse; - valid claims in
/me; - an optional username;
- no tokens in the response;
- a defensive
401without 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.
- Linux/macOS
- PowerShell
- CMD
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
Set-Location rest-api-cognito-authorizer
py -3.14 -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt
cd rest-api-cognito-authorizer
py -3.14 -m venv .venv
.venv\Scripts\activate.bat
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.
- Linux/macOS
- PowerShell
- CMD
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.
$StackName = "RestApiCognitoAuthorizerStack"
$UserPoolId = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue' --output text
$ClientId = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue' --output text
$ApiUrl = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' --output text
$Username = Read-Host "Cognito username"
$SecurePassword = Read-Host "Cognito password" -AsSecureString
$Bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecurePassword)
try { $Password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Bstr) }
aws cognito-idp admin-create-user --user-pool-id $UserPoolId --username $Username --temporary-password $Password --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id $UserPoolId --username $Username --password $Password --permanent
Read-Host -AsSecureString hides input; the converted string exists only temporarily for the AWS CLI process.
set STACK_NAME=RestApiCognitoAuthorizerStack
for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue" --output text') do set USER_POOL_ID=%i
for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue" --output text') do set CLIENT_ID=%i
for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue" --output text') do set API_URL=%i
set /p USERNAME=Cognito username:
set /p PASSWORD=Cognito password:
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
set /p may leave the password visible in CMD. Use hidden input where possible and clear the variables afterward.
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.
- Linux/macOS
- PowerShell
- CMD
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)
$Auth = aws cognito-idp initiate-auth --client-id $ClientId --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=$Username,PASSWORD=$Password | ConvertFrom-Json
$IdToken = $Auth.AuthenticationResult.IdToken
for /f "delims=" %i in ('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') do set ID_TOKEN=%i
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.
- Linux/macOS
- PowerShell
- CMD
curl --fail "$API_URL/health"
curl -i "$API_URL/me"
curl --fail -H "Authorization: $ID_TOKEN" "$API_URL/me"
Invoke-RestMethod "$ApiUrl/health"
Invoke-WebRequest "$ApiUrl/me" -SkipHttpErrorCheck
Invoke-RestMethod "$ApiUrl/me" -Headers @{ Authorization = $IdToken }
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
Destroying the stack permanently deletes the Cognito User Pool and all users stored in it.