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 a supported identity; 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 CurrentUserError, GET, current_user, public
JSON_HEADERS = {"Content-Type": "application/json"}
@public
@GET("/health")
def health(event, context):
return {
"statusCode": 200,
"headers": JSON_HEADERS,
"body": json.dumps({"status": "ok"}),
}
@GET("/me")
def me(event, context):
try:
user = current_user(event)
except CurrentUserError:
return {
"statusCode": 401,
"headers": JSON_HEADERS,
"body": json.dumps({"message": "Unauthorized"}),
}
body = {"sub": user.subject}
if user.username is not None:
body["username"] = user.username
return {
"statusCode": 200,
"headers": JSON_HEADERS,
"body": json.dumps(body),
}
health returns a proxy response for the public route. me delegates identity
normalization to current_user(event).
API Gateway validates the Cognito ID token before invoking the Lambda, so the
helper receives claims that have already passed the authorizer. The helper
supports REST API Cognito claims at
requestContext.authorizer.claims and HTTP API v2 JWT claims at
requestContext.authorizer.jwt.claims; this example uses REST API plus a
Cognito User Pools Authorizer.
CurrentUser.subject comes from the required sub claim. CurrentUser.username
uses cognito:username, then username, and otherwise is None.
CurrentUser.claims is a read-only snapshot, but this handler does not return
it. If the event has no supported identity or has invalid structure,
current_user raises CurrentUserError, which the handler converts into a
defensive 401 without exposing the exception text. The helper does not
authenticate, validate JWT signatures, or authorize actions; a request without
a valid token is rejected by API Gateway before the Lambda is invoked.
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:
- that
/medelegates the same event object tocurrent_user; - use of
CurrentUser.subjectand normalizedCurrentUser.username; - that
usernamemay beNone; - conversion of
CurrentUserErrorinto a defensive401; - no tokens or complete claims mapping in the response;
- the
/healthresponse remains public; - no AWS calls;
- two functions in one module;
- one route per function;
/meinherits the Cognito authorizer.
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 the handler and infrastructure tests, then runs
cdk synth --quiet. Docker is required for Lambda bundling. 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.4 for the Lambda package and lambda-api-decorators-cdk==0.4.7 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.