Introduction
Securing APIs is one of those topics that looks simple at first, but quickly becomes complex in real-world scenarios.
When you move to a serverless architecture, this challenge becomes even more critical.
Many implementations end up pushing authentication logic into Lambda functions, creating custom token validation, extra code paths, and unnecessary operational overhead.
In this article, I will walk through a practical and production-ready approach to secure an AWS API Gateway endpoint using Amazon Cognito and the native JWT Authorizer, without Lambda authorizers or custom authentication logic.
This setup is cost-efficient, scalable, and relies entirely on managed AWS services.
All the reference code and supporting artifacts used in this article are available in the following GitHub repository:
aws-secure-serverless-api-cognito-jwt
Architecture Overview
The architecture used in this experiment is intentionally simple:
Amazon Cognito User Pool for authentication
- API Gateway HTTP API
- Native JWT Authorizer
- AWS Lambda as the backend
- CloudWatch Logs for observability
The key idea is to let Amazon Cognito handle authentication, while API Gateway is responsible for validating JWTs before the request ever reaches Lambda.
This means invalid or expired tokens never invoke your backend code.
The full request flow is illustrated in the diagram below:
Step 1: Creating the Cognito User Pool and App Client
We start by creating a Cognito User Pool and an App Client.
Important configuration points:
- Enable USER_PASSWORD_AUTH
- Disable client secret (required for public clients and Postman testing)
- Use email as a sign-in alias
This App Client will be responsible for issuing Access Tokens and ID Tokens.
This corresponds to step 1 in the architecture, where the user authenticates directly with Amazon Cognito.
User confirmation requirement:
When users are created manually or via the AWS Console, they may initially be in the FORCE_CHANGE_PASSWORD or UNCONFIRMED state.
For this experiment, the user must be fully confirmed before authentication succeeds.
To ensure this, the user account was confirmed using AWS CloudShell and the AWS CLI, setting a permanent password and moving the user to the CONFIRMED state.
This step is important because Cognito will reject authentication attempts for users that are not fully confirmed.
Once the user is confirmed, authentication via Postman works as expected.
Step 2: Authenticating with Cognito
Authentication is performed using the InitiateAuth API.
Using Postman, we send a request to the Cognito endpoint with:
- AuthFlow: USER_PASSWORD_AUTH
- ClientId
- Username and password
If authentication succeeds, Cognito returns:
- AccessToken
- IdToken
- RefreshToken
At this point, we already have everything needed to call a protected API.
Step 3: Configuring the API Gateway JWT Authorizer
Instead of using a Lambda Authorizer, we configure a native JWT Authorizer directly in API Gateway.
Key settings:
- Issuer: Cognito User Pool issuer URL
- Audience: Cognito App Client ID
- Identity source: Authorization header
This tells API Gateway exactly how to validate incoming JWTs automatically.
Step 4: Protecting the API Route
Next, we attach the JWT Authorizer to the /secure route.
Once attached:
- Requests without a token return 401 Unauthorized
- Requests with invalid or expired tokens are rejected
- Only valid tokens are allowed through
All of this is enforced by API Gateway itself, before Lambda is invoked.
Step 5: Lambda Backend and Token Claims
At this stage, authentication and token validation are already completed.
The Lambda function does not validate JWTs, does not decode tokens, and does not contain authentication logic.
API Gateway injects the decoded JWT claims directly into the request context, under:
event.requestContext.authorizer.jwt.claims
Below is the complete Lambda function used as the secure backend:
import json
def lambda_handler(event, context):
print("Lambda secure-api-backend INVOCADA")
print("RequestId:", context.aws_request_id)
claims = event["requestContext"]["authorizer"]["jwt"]["claims"]
print("JWT claims recebidas:")
print(json.dumps({
"sub": claims.get("sub"),
"email": claims.get("email"),
"username": claims.get("cognito:username"),
"issuer": claims.get("iss"),
"token_use": claims.get("token_use"),
"scope": claims.get("scope")
}))
response = {
"message": "Authorized request",
"user": {
"sub": claims.get("sub"),
"email": claims.get("email"),
"username": claims.get("cognito:username"),
"issuer": claims.get("iss"),
"token_use": claims.get("token_use"),
"scope": claims.get("scope")
}
}
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps(response)
}
This keeps the backend extremely simple and focused:
- API Gateway handles security
- Lambda consumes already validated identity data
- Logs provide full traceability
Step 6: Validation and Results
We tested three different scenarios:
Invalid token
→ 401 Unauthorized

Valid Access Token
→ 200 OK and Lambda executed

CloudWatch Logs confirm that the Lambda function is only invoked when the token is valid, proving that the security boundary is enforced at the API Gateway level.
Reference Implementation
The complete reference implementation, including the Lambda function, Postman collection, and architecture diagram, is available on GitHub:
aws-secure-serverless-api-cognito-jwt
Conclusion
Using Amazon Cognito together with API Gateway JWT Authorizers is one of the cleanest and most efficient ways to secure serverless APIs on AWS.
There is no custom authentication code, no Lambda Authorizers, and no additional infrastructure to manage.
API Gateway becomes the security gate, and Lambda focuses only on business logic.
This is a pattern I recommend for modern, scalable, and secure serverless applications.












Top comments (0)