Secure a Serverless API with SAM
Deploy a Cognito-protected Products API using a single SAM template.
Introduction
30 Second Summary
Imagine building a full backend by clicking through dozens of console screens. Now imagine doing it again when you need a second environment, or when a teammate needs the same setup. Every manual click is a step you might forget or misconfigure next time.
In this project, you will define a complete serverless API with authentication in a single YAML file and deploy it to AWS with one command using SAM (Serverless Application Model). Your Lambda function, DynamoDB table, API Gateway, and Cognito authentication all come from that one file.
What You'll Build
A Products API that rejects anyone without a valid token, deployed entirely through Infrastructure as Code. You will type one deploy command and watch your entire authenticated backend appear in AWS.
By the end of this project, you'll have:
- A SAM template that provisions a full serverless stack (Lambda, DynamoDB, API Gateway, Cognito) and deploys it in under two minutes with sam deploy --guided.
- A live API endpoint that returns product data when you call it with a valid JWT token, and rejects unauthenticated requests with a 401 Unauthorized.
- A repeatable deployment lifecycle where you can modify the template, redeploy, and see infrastructure changes applied automatically.
- Secret Mission: Add a protected Orders endpoint to the same template, proving that expanding IaC is just more YAML.
Are there any prerequisites?
You need an AWS account and basic familiarity with Lambda, DynamoDB, and API Gateway. If you have completed a project like "Build a Serverless Microservices Backend" or have built these resources manually in the console before, you are ready.
Step 1 walks you through installing all required tools (Python, AWS CLI, SAM CLI) from scratch.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You've built serverless APIs before by clicking through the AWS console. This time, you'll define that same infrastructure in a single YAML file and deploy it with one command.
Set Up Your Development Environment
To define and deploy your serverless API as Infrastructure as Code, you need the SAM CLI toolchain installed on your machine. Without these tools, you would be stuck clicking through the AWS console manually for every resource you create.
In this step, you will install Python, the AWS CLI, and the AWS SAM CLI. These three tools work together to let you build and deploy serverless applications from your terminal with a single command.
In this step, get ready to:
- Install Python 3.13 and the AWS CLI on your machine.
- Configure the AWS CLI with your credentials and default region.
- Install the AWS SAM CLI and enable Windows long paths.
Install Python and the AWS CLI
The SAM CLI requires Python to run your Lambda function code locally, and the AWS CLI connects your terminal to your AWS account.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check whether Python is already installed by running this command:
python --version
✔️ I see Python 3.13
Python 3.13 is ready to go. Skip ahead to checking the AWS CLI below.
ⓧ I see an older version
You have Python installed, but it is not version 3.13. You need to install the correct version.
- Open https://www.python.org/downloads/ in your browser.
- Download the Python 3.13 installer for Windows.
- Run the installer. On the first page, check the box labelled Add Python to PATH before clicking Install Now.
- After installation completes, close your PowerShell window and open a new one.
- Run python --version again to confirm you now see 3.13.
Still seeing the old version?
If the old version still appears, the new install may not be first in your PATH. Re-run the Python 3.13 installer, select Modify, and ensure the Add Python to PATH option is checked.
help me fix my Python version on Windows
ⓧ Command not found
Python is not installed yet. Follow these steps to install it.
- Open https://www.python.org/downloads/ in your browser.
- Download the Python 3.13 installer for Windows.
- Run the installer. On the first page, check the box labelled Add Python to PATH before clicking Install Now.
- After installation completes, close your PowerShell window and open a new one.
- Run python --version to confirm you see 3.13.
Still not recognized?
Make sure you checked Add Python to PATH during installation. If you missed it, re-run the installer and select Modify to enable the PATH option.
help me get Python recognized in PowerShell
- Now check whether the AWS CLI is installed by running:
aws --version
✔️ I see aws-cli/2.x.x
AWS CLI v2 is ready. Skip ahead to configuring it below.
ⓧ I see aws-cli/1.x.x
You have AWS CLI version 1 installed. Version 2 includes features that SAM requires. Install v2 to replace it.
- Download the AWS CLI v2 MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
- Run the installer and follow the on-screen prompts. It will replace version 1 automatically.
- Close your PowerShell window and open a new one.
- Run aws --version again to confirm you see aws-cli/2 in the output.
ⓧ Command not found
The AWS CLI is not installed yet. Follow these steps to install it.
- Download the AWS CLI v2 MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
- Run the installer and follow the on-screen prompts.
- Close your PowerShell window and open a new one.
- Run aws --version to confirm you see aws-cli/2 in the output.
Still not recognized after installing?
Make sure you opened a brand new PowerShell window after the installer finished. The PATH update only takes effect in new terminal sessions.
help me get AWS CLI working in PowerShell
- Configure the AWS CLI with your account credentials by running:
aws configure
What will this command ask for?
The command prompts you for four values one at a time:
- AWS Access Key ID - paste your access key from the AWS console.
- AWS Secret Access Key - paste your secret key.
- Default region name - type us-east-1.
- Default output format - type json.
If you do not have an access key yet, go to the IAM console in AWS, select your user, click Security credentials, and create one.
- Verify the configuration saved correctly by running:
aws sts get-caller-identity
You should see a JSON response showing your AWS account ID, user ARN, and user ID. This confirms the CLI can talk to your account.
Seeing an access denied or invalid credentials error?
Double-check that you pasted the correct Access Key ID and Secret Access Key during aws configure. Run aws configure again to re-enter them.
help me fix my AWS CLI credentials
Install the AWS SAM CLI
The SAM CLI is what transforms your YAML template into real AWS resources. It handles building your Lambda code and deploying the full stack.
- Download the AWS SAM CLI 64-bit MSI installer from the official install page.
- Run the downloaded MSI installer and follow the on-screen prompts to complete installation.
- Close your current PowerShell window and open a new one so the PATH update takes effect.
Before you run the next command, what output do you expect if SAM installed correctly?
- Confirm the installation by running:
sam --version
✔️ I see a version number
You should see output like SAM CLI, version 1.x.x. The SAM CLI is ready to go.
ⓧ Command not found
The SAM CLI is not on your PATH yet. This usually means you need to open a brand new PowerShell window after the installer finished.
- Close all open PowerShell windows completely.
- Open a fresh PowerShell window using the Windows key search.
- Run sam --version again.
If it still does not work, re-run the SAM CLI MSI installer to ensure it completed successfully.
help me get SAM CLI recognized in PowerShell
One final configuration step is needed. SAM creates deeply nested folder paths during builds, and Windows has a default 260-character path limit that can cause failures.
- Press the Windows key to open your search bar.
- Type regedit and press Enter to open Registry Editor. Click Yes if prompted by User Account Control.
- In the left panel, navigate to: HKEY_LOCAL_MACHINE > SYSTEM > CurrentControlSet > Control > FileSystem.
- In the right panel, double-click LongPathsEnabled.
- Set the Value data to 1 and click OK.
- Close Registry Editor.
Why enable long paths?
SAM builds create nested directories that can exceed 260 characters. Without this setting, build commands may fail with cryptic file-not-found errors.
- Verify the change took effect by running this in PowerShell:
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled"
You should see LongPathsEnabled : 1 in the output. This confirms Windows will allow the long file paths that SAM needs.
Seeing a value of 0 or an error?
If the value shows 0, go back to Registry Editor and confirm you edited the correct key under FileSystem. Make sure you changed LongPathsEnabled to 1, not a different entry.
help me enable long paths in the Windows registry
Your development environment is ready. Next up, you will define an entire Products API in a single YAML file and deploy it to AWS with one command.
Deploy a Products API with SAM
In your previous project, you built a Products API by clicking through the AWS console for 20+ minutes. You created a DynamoDB table, wrote a Lambda function, wired up an HTTP API, and configured IAM permissions one screen at a time.
Now you will define that entire stack in a single YAML file and deploy it with one command. This is Infrastructure as Code: your infrastructure lives in a file you can version, share, and redeploy in seconds.
In this step, get ready to:
- Write a SAM template that defines a DynamoDB table, Lambda function, and HTTP API.
- Build and deploy the entire stack with two commands.
- Test the live API endpoint from PowerShell.
Create the project structure and Lambda function code
Your project needs three things: a folder structure, a Lambda function (Python), and a SAM template (YAML) that tells AWS what to create.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, a subfolder for the Lambda code, and an empty requirements file by running these commands:
mkdir sam-products-api
cd sam-products-api
mkdir products
New-Item products/requirements.txt
- Confirm the folder structure exists by running:
ls products/
You should see requirements.txt listed inside the products folder.
Why is requirements.txt empty?
Lambda's Python runtime already includes boto3 (the AWS SDK). Since that is the only dependency your function needs, there is nothing to list.
Now you will write the Lambda function. This handler routes incoming API requests to the correct DynamoDB operation based on the HTTP method and path.
- Create products/app.py and paste the following code to set up imports, the DynamoDB connection, and the DELETE route handler:
import json
import os
import boto3
from decimal import Decimal
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
body = {}
status_code = 200
headers = {"Content-Type": "application/json"}
try:
route_key = event.get("routeKey", "")
if route_key == "DELETE /products/{id}":
product_id = event["pathParameters"]["id"]
table.delete_item(Key={"id": product_id})
body = f"Deleted product {product_id}"
What does this code do?
The function reads TABLE_NAME from an environment variable so it never hardcodes the DynamoDB table name. SAM injects this value at deploy time.
The routeKey field (provided by API Gateway V2) tells the handler which HTTP method and path were called. The DELETE branch removes a product by its ID.
- Directly below the DELETE handler, add the two GET route handlers:
elif route_key == "GET /products/{id}":
product_id = event["pathParameters"]["id"]
response = table.get_item(Key={"id": product_id})
item = response.get("Item")
if item:
body = {
"id": item["id"],
"name": item["name"],
"price": float(item["price"]),
}
else:
status_code = 404
body = f"Product {product_id} not found"
elif route_key == "GET /products":
response = table.scan()
items = response.get("Items", [])
body = [
{
"id": item["id"],
"name": item["name"],
"price": float(item["price"]),
}
for item in items
]
What does this code do?
The first branch fetches a single product by ID. It returns a 404 if the item does not exist in the table.
The second branch scans the entire table and returns all products as a JSON list. The float() conversion is needed because DynamoDB stores numbers as Decimal objects, which cannot be serialized to JSON directly.
- Complete the function by adding the PUT handler, the fallback else branch, error handling, and the return statement:
elif route_key == "PUT /products":
request_body = json.loads(event["body"])
table.put_item(
Item={
"id": request_body["id"],
"name": request_body["name"],
"price": Decimal(str(request_body["price"])),
}
)
body = f"Created product {request_body['id']}"
else:
status_code = 400
body = f"Unsupported route: {route_key}"
except KeyError as e:
status_code = 400
body = f"Missing required field: {str(e)}"
except Exception as e:
status_code = 500
body = f"Error: {str(e)}"
return {
"statusCode": status_code,
"headers": headers,
"body": json.dumps(body),
}
What does this code do?
The PUT branch parses the request body and writes a new item to DynamoDB. It wraps the price in Decimal() because DynamoDB does not accept Python floats directly.
The except blocks catch missing fields and unexpected errors, returning structured error responses instead of crashing the Lambda.
- Save products/app.py.
- Confirm the file was saved by running:
ls products/
You should see both app.py and requirements.txt listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved products/app.py before continuing.
ⓧ I'd like to double check the full code
Scroll up and confirm your products/app.py contains all three code chunks in order:
- Chunk 1: imports, DynamoDB setup, lambda_handler start, and the DELETE handler.
- Chunk 2: GET by ID handler and GET all products handler.
- Chunk 3: PUT handler, else branch, except blocks, and the return statement.
Seeing a file not found error?
Make sure you ran cd sam-products-api earlier. The products/ folder only exists inside your project directory.
Help me find my products/app.py file
Next, you will create the SAM template. This single YAML file replaces all the manual console clicks from your previous project.
- Create template.yaml in your project root (sam-products-api) and paste the following header, globals, table, and API definition:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Products API with Cognito authentication
Globals:
Function:
Timeout: 10
Runtime: python3.13
Architectures:
- x86_64
Resources:
ProductsTable:
Type: AWS::Serverless::SimpleTable
Properties:
PrimaryKey:
Name: id
Type: String
ProductsApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
What does this code do?
The Transform line tells CloudFormation to process SAM-specific shorthand before creating resources. Without it, CloudFormation would not understand types like AWS::Serverless::SimpleTable.
The Globals section sets defaults (timeout, runtime, architecture) that apply to every Lambda function in the template. ProductsTable creates a DynamoDB table with a string partition key named id. ProductsApi creates an HTTP API with a prod stage.
- Directly below the ProductsApi resource, add the Lambda function definition with its policy, environment variable, and first two API events:
ProductsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: products/
Handler: app.lambda_handler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ProductsTable
Environment:
Variables:
TABLE_NAME: !Ref ProductsTable
Events:
GetProducts:
Type: HttpApi
Properties:
ApiId: !Ref ProductsApi
Path: /products
Method: GET
GetProduct:
Type: HttpApi
Properties:
ApiId: !Ref ProductsApi
Path: /products/{id}
Method: GET
What does this code do?
DynamoDBCrudPolicy is a SAM policy template that grants your function full CRUD permissions on the specified table. One line replaces a custom IAM policy document.
The !Ref ProductsTable syntax references the table's auto-generated physical name. SAM resolves this at deploy time so you never hardcode resource names. Each Events entry wires an API route to this function.
- Still inside the Events section (at the same indentation level as GetProducts), add the PUT and DELETE events plus the Outputs section at the bottom of the file:
CreateProduct:
Type: HttpApi
Properties:
ApiId: !Ref ProductsApi
Path: /products
Method: PUT
DeleteProduct:
Type: HttpApi
Properties:
ApiId: !Ref ProductsApi
Path: /products/{id}
Method: DELETE
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub https://${ProductsApi}.execute-api.${AWS::Region}.amazonaws.com/prod
What does this code do?
The CreateProduct and DeleteProduct events complete the CRUD routes. Every route points to the same Lambda function.
The Outputs section prints the live API URL after deployment finishes. !Sub performs string substitution, injecting the generated API ID and region into the URL.
- Save template.yaml.
✔️ Awesome, I've got everything!
Great. Make sure template.yaml is saved in the root of sam-products-api (not inside the products/ subfolder).
ⓧ I'd like to double check the full code
Scroll up and confirm your template.yaml contains all three chunks in order:
- Chunk 1: AWSTemplateFormatVersion, Transform, Description, Globals, ProductsTable, and ProductsApi.
- Chunk 2: ProductsFunction with CodeUri, Handler, Policies, Environment, and the GetProducts/GetProduct events.
- Chunk 3: CreateProduct and DeleteProduct events, then the Outputs section.
YAML indentation issues?
YAML is sensitive to spaces. Make sure ProductsFunction is indented 2 spaces under Resources: (same level as ProductsTable). Each Events entry should be 8 spaces from the left margin.
If your editor converts tabs to a different number of spaces, switch to 2-space indentation for this file.
Help me fix my YAML spacing
Build and deploy the stack
With your files in place, SAM can now package the Lambda code and deploy everything to AWS in two commands.
- Build the project artifacts by running this command:
sam build
You should see Build Succeeded in the output.
What does sam build do?
The sam build command installs dependencies listed in requirements.txt, copies your function source code, and stages everything into a .aws-sam/build directory. This prepared package is what gets uploaded to AWS during deployment.
Build failed?
Check that you are running the command from inside the sam-products-api folder (where template.yaml lives). Run ls to confirm you can see template.yaml in the current directory.
If you see a YAML parsing error, double check that your indentation uses spaces (not tabs) and matches the chunks above exactly.
Help me diagnose my build error
- Deploy the stack to AWS by running this command:
sam deploy --guided
What will sam deploy --guided ask me?
This command launches an interactive flow. It will prompt you for several configuration values. Default values are shown in brackets. Press Enter to accept a default, or type a new value.
- Stack Name: type sam-products-api.
- AWS Region: type us-east-1.
- Confirm changes before deploy: press Enter to accept the default.
- Allow SAM CLI IAM role creation: type Y.
- Disable rollback: press Enter to accept the default.
- ProductsFunction may not have authorization defined, Is this okay?: type y.
- Save arguments to configuration file: type Y.
- SAM configuration file: press Enter to accept samconfig.toml.
- SAM configuration environment: press Enter to accept default.
Wait for CloudFormation to finish creating your resources. This takes about 60-90 seconds.
When deployment completes, you will see an Outputs section in your terminal containing the ApiUrl value.
- Copy the API URL from the Outputs section and store it in a PowerShell variable by running:
$ApiUrl = "[[APIURL="https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod"]]"
Keep this terminal open
The $ApiUrl variable only exists in your current PowerShell session. If you close the window, you will need to set it again.
Deployment failed?
If you see a rollback or CREATE_FAILED status, the most common cause is a YAML indentation error in your template. Run sam build again first. If the build succeeds but deploy fails, check the CloudFormation console for a detailed error message on the failed resource.
If the authorization prompt caused a deployment failure, you may have answered N instead of y. Run sam deploy --guided again and type y at that prompt.
Help me fix my deployment error
Test the live API from PowerShell
Your entire stack is now live in AWS. Time to prove it works by creating a product and reading it back.
- Create a product by sending a PUT request with JSON data:
Invoke-RestMethod -Uri "$ApiUrl/products" -Method PUT -Body '{"id": "1", "name": "Cloud Widget", "price": 9.99}' -ContentType "application/json"
You should see Created product 1 returned in your terminal.
What does this command do?
Invoke-RestMethod is PowerShell's built-in HTTP client. It sends a PUT request with a JSON body to your /products endpoint. The Lambda function parses the body and writes the item to DynamoDB.
Before you retrieve the product list, what do you expect to see returned? Think about what is in the DynamoDB table right now.
- Retrieve all products by running:
Invoke-RestMethod -Uri "$ApiUrl/products"
You should see a list containing your product: id of 1, name of Cloud Widget, and price of 9.99.
Why does this matter?
You just created a DynamoDB table, a Lambda function, an HTTP API with four routes, and all the IAM permissions connecting them. In your previous project, this required 20+ minutes of clicking through the AWS console. With SAM, it took one YAML file and two commands.
Notice one important detail: this API is currently wide open. Anyone who discovers the URL can create, read, and delete your products with no authentication required. You will fix this in the next step.
Getting a 500 error or no response?
Make sure your $ApiUrl variable does not have a trailing slash. Run echo $ApiUrl to check its value ends with /prod and nothing after.
If you see an Internal Server Error, check that products/app.py has no indentation errors. A single misaligned line causes a Lambda import failure.
Help me troubleshoot my API error
Your serverless API is live and working. Next up, you will lock it down with Cognito authentication by adding a few more lines to the same template file.
Add Cognito Authentication to the Template
Your Products API is live, but anyone with the URL can read, create, and delete your data. In a console-first workflow, adding authentication would mean clicking through the Cognito service, creating a User Pool, configuring an app client, then wiring it to your API Gateway manually.
With Infrastructure as Code, you add authentication by editing your template file. In this step, you will add Cognito resources and a JWT authorizer to template.yaml, then redeploy with one command to lock down your API.
In this step, get ready to:
- Add a Cognito User Pool and app client to your SAM template.
- Configure a JWT authorizer on your HTTP API.
- Redeploy and confirm unauthenticated requests are rejected.
Add Cognito resources to template.yaml
You need three things to protect your API with Cognito: a User Pool (where user accounts live), an app client (how your API talks to the pool), and an authorizer (the gate that checks tokens on every request).
- In template.yaml, find the ProductsTable resource. Add the following two new resources directly below it, before the ProductsApi resource:
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: products-api-users
AutoVerifiedAttributes:
- email
UsernameAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: products-api-client
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
What do these resources do?
- UserPool creates a Cognito User Pool named products-api-users. Users sign up with their email, and the minimum password length is 8 characters.
- UserPoolClient creates an app client that allows direct username/password authentication. GenerateSecret: false means no client secret is needed, which is required for public clients like CLI tools.
- Now find the ProductsApi resource. Add the following Auth section directly below the StageName: prod line:
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
AuthorizationScopes:
- aws.cognito.signin.user.admin
JwtConfiguration:
issuer: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}
audience:
- !Ref UserPoolClient
IdentitySource: $request.header.Authorization
What does the JWT authorizer do?
This section tells API Gateway to check every incoming request for a valid JWT token in the Authorization header.
- issuer points to your Cognito User Pool URL so API Gateway knows which identity provider to trust.
- audience references your app client ID. Only tokens issued to this specific client are accepted.
- DefaultAuthorizer applies this check to ALL routes automatically. No route is left unprotected.
- Confirm your template is valid by running this command:
sam build
You should see Build Succeeded in the output. This proves your YAML syntax and resource references are correct.
Seeing a template error?
- Check your indentation carefully. YAML is whitespace-sensitive. The Auth section must be indented at the same level as StageName (6 spaces from the left edge).
- Make sure !Ref UserPool and !Ref UserPoolClient match the exact resource names you defined above (case-sensitive).
Help me debug my SAM template build error
- Finally, scroll down to the Outputs section at the bottom of template.yaml. Add these two new entries below the existing ApiUrl output:
UserPoolId:
Description: Cognito User Pool ID
Value: !Ref UserPool
UserPoolClientId:
Description: Cognito User Pool Client ID
Value: !Ref UserPoolClient
Why output these IDs?
CloudFormation generates unique IDs for your User Pool and Client at deploy time. By adding them as Outputs, you can easily grab them from the terminal after deployment instead of hunting through the AWS console.
- Save template.yaml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file, then continue to the next substep.
ⓧ I'd like to double check the full code
Your template.yaml is now over 80 lines. Scroll through it and confirm these sections are present, in this order:
- ProductsTable (SimpleTable, unchanged from before).
- UserPool (Cognito UserPool with email sign-up and password policy).
- UserPoolClient (Cognito client with ALLOW_USER_PASSWORD_AUTH and ALLOW_REFRESH_TOKEN_AUTH).
- ProductsApi (HttpApi with StageName: prod, plus the new Auth section containing CognitoAuthorizer with JwtConfiguration).
- ProductsFunction (unchanged from before).
- Outputs section with ApiUrl, UserPoolId, and UserPoolClientId.
Redeploy and witness the lockdown
Your template now defines everything needed for authentication. Time to push those changes to AWS and see what happens to your previously-open API.
- Build and deploy the updated stack by running these commands:
sam build
sam deploy
Why no --guided this time?
Your first deploy created samconfig.toml, which saved all your deployment settings (stack name, region, IAM permissions). Every subsequent deploy reuses those settings automatically.
Wait about 60-90 seconds for CloudFormation to create the Cognito resources and apply the authorizer to your API.
Before you test, think about this: you are about to make the exact same GET request that returned your product data in the previous step. What do you expect to see now that the JWT authorizer is active?
- Test your API by running the same request from before:
Invoke-RestMethod -Uri "$ApiUrl/products"
PowerShell throws an error containing 401 (Unauthorized). That is exactly what you want. The API now rejects every request that does not include a valid JWT token in the Authorization header.
What just happened?
By adding three YAML blocks to your template and running one deploy command, you went from a wide-open API to a fully authenticated one. No console clicking, no manual wiring. That is the power of Infrastructure as Code.
Still getting a 200 response with data?
- Make sure sam deploy completed successfully. Look for Successfully created/updated stack in the output.
- Confirm you set DefaultAuthorizer: CognitoAuthorizer in the Auth section. Without this line, routes remain open even though the authorizer is defined.
- Check that the Auth section is inside ProductsApi Properties, at the same indentation level as StageName.
Help me debug why my SAM API is not requiring authentication
Your API is locked down. The authorizer rejects anyone who does not prove their identity. Next up, you will create a user, sign in, and prove the full authentication flow works end-to-end.
Authenticate and Access the Protected API
Your API now correctly rejects unauthenticated requests with a 401 Unauthorized. That is the lock working. Now you need to prove you hold the key.
In this step, you will create a user in your Cognito User Pool, sign in to obtain a JWT token, and call your protected API with it. This completes the full authentication flow: rejected without a token, accepted with one.
In this step, get ready to:
- Create and confirm a Cognito user via the AWS CLI.
- Sign in and obtain a JWT token.
- Call the protected API with and without the token to prove authentication works.
Sign up and confirm a user via the AWS CLI
Before you can sign in, you need a user in the pool. You will create one directly from the command line using the Cognito CLI commands.
- First, grab your User Pool ID and Client ID from the stack outputs by running this command:
aws cloudformation describe-stacks --stack-name sam-products-api --query "Stacks[0].Outputs" --output table
You should see a table listing ApiUrl, UserPoolId, and UserPoolClientId.
- Store the User Pool ID and Client ID in PowerShell variables by running these commands (replace the placeholder values with the ones from your output):
$UserPoolId = [[USERPOOLID="your User Pool ID from the output above"]]
$UserPoolClientId = [[USERPOOLCLIENTID="your User Pool Client ID from the output above"]]
- Create a new user by running this sign-up command:
aws cognito-idp sign-up --client-id $UserPoolClientId --username testuser@example.com --password TestPass123! --user-attributes Name="email",Value="testuser@example.com"
What does this command do?
- aws cognito-idp sign-up registers a new user in your Cognito User Pool.
- --client-id tells Cognito which app client to use (the one your template created).
- --user-attributes attaches the email address so Cognito treats it as the username (since your pool is configured for email sign-in).
You should see a JSON response with UserConfirmed: false and a UserSub UUID.
- Confirm the user immediately (bypassing email verification) by running:
aws cognito-idp admin-confirm-sign-up --user-pool-id $UserPoolId --username testuser@example.com
Why admin-confirm-sign-up?
Normally Cognito sends a verification email. For testing, the admin-confirm command skips that step and activates the account instantly. This command produces no output on success.
Seeing an InvalidParameterException?
- Make sure your $UserPoolId and $UserPoolClientId variables are set correctly. Run echo $UserPoolId to check.
- Confirm the password meets the 8-character minimum your template requires.
- help me troubleshoot my Cognito sign-up command
Obtain a JWT token and call the protected API
With a confirmed user, you can now sign in. The sign-in response includes a JWT that your API Gateway authorizer accepts.
- Sign in and obtain an ID token by running this command:
$AuthResult = aws cognito-idp initiate-auth --client-id $UserPoolClientId --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=testuser@example.com,PASSWORD=TestPass123! | ConvertFrom-Json
$IdToken = $AuthResult.AuthenticationResult.IdToken
What does this command do?
- aws cognito-idp initiate-auth signs in the user and returns authentication tokens.
- --auth-flow USER_PASSWORD_AUTH tells Cognito to accept a username and password directly (the flow your template enabled with ALLOW_USER_PASSWORD_AUTH).
- The response JSON is piped to ConvertFrom-Json so you can extract the IdToken into a PowerShell variable.
- Verify the token was captured by running:
$IdToken.Substring(0, 50)
You should see the first 50 characters of a long encoded string starting with eyJ.
Seeing NotAuthorizedException?
- Double-check that your username and password match exactly what you used in the sign-up command.
- Make sure there is no space after the comma in USERNAME=...,PASSWORD=... (spaces break the parameter parsing).
- help me troubleshoot my Cognito initiate-auth command
Now for the moment of truth. Before you run this next command, what do you think will happen when you call the same endpoint that returned 401 earlier, but this time with a valid token?
- Call the protected GET endpoint with your token by running:
Invoke-RestMethod -Uri "$ApiUrl/products" -Headers @{ Authorization = $IdToken }
You should see a 200 response with your product data (or an empty list [] if you deleted your test product earlier). The same endpoint that rejected you moments ago now returns data.
- Create a new product using the authenticated PUT endpoint by running:
$ProductBody = @{ id = "p100"; name = "Authenticated Widget"; price = 29.99 } | ConvertTo-Json
Invoke-RestMethod -Uri "$ApiUrl/products" -Method PUT -Body $ProductBody -Headers @{ Authorization = $IdToken } -ContentType "application/json"
You should see the response Created product p100.
- Confirm the product was stored by retrieving it:
Invoke-RestMethod -Uri "$ApiUrl/products" -Headers @{ Authorization = $IdToken }
You should see your Authenticated Widget in the returned list.
- Finally, prove that removing the token still locks you out by running the same request without the Authorization header:
try { Invoke-RestMethod -Uri "$ApiUrl/products" } catch { $_.Exception.Response.StatusCode }
You should see Unauthorized confirming the API rejects requests without a valid token.
What did you just prove?
You completed the full authentication loop. Without a token the API returns 401. With a valid Cognito JWT, the same endpoint returns 200 with data. Your one YAML file defines the entire security boundary.
Clean up or keep your stack
Every resource in your stack is within the AWS Free Tier. You can leave it running at no cost, or tear it all down with a single command.
✔️ I want to keep it running
No action needed. All resources (Lambda, DynamoDB, API Gateway, Cognito) are serverless and idle within the free tier. You will not be charged.
ⓧ I want to tear everything down
- Delete the entire stack by running:
sam delete
When prompted to confirm, enter y. SAM removes the CloudFormation stack and all associated resources (Lambda, DynamoDB table, API Gateway, Cognito User Pool, IAM roles) in one command.
You have gone from a manually-built console API to a fully authenticated, infrastructure-as-code serverless backend. Next up, extend this template even further in the Secret Mission.
Secret mission
Add a Protected Orders Endpoint
In your first project, adding a second microservice meant repeating the entire manual setup process. With SAM, it is just more YAML in the same file. Can you add a protected Orders endpoint to prove that IaC scales effortlessly?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on serverless AWS services within the free tier, so there are no ongoing charges while resources sit idle.
Resources you used:
- CloudFormation stack sam-products-api (Lambda functions, DynamoDB tables, HTTP API, Cognito User Pool, IAM roles).
- SAM-managed S3 bucket (auto-created for deployment artifacts).
- Local project folder sam-products-api on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project further.
- All resources are serverless and idle within the AWS Free Tier. Lambda, DynamoDB, API Gateway, and Cognito incur no charges when not receiving requests.
- Your local sam-products-api folder stays on your Desktop for future edits.
- Python, AWS CLI, and SAM CLI remain installed for other projects.
Pause - I'll come back to this later
Remove the cloud resources but keep your local files so you can redeploy later with one command.
- Open PowerShell and navigate to your sam-products-api folder on the Desktop.
- Run the following command to delete the CloudFormation stack and deployment artifacts:
sam delete
- When prompted, confirm you want to delete the stack and the S3 template file by entering y for each prompt.
- Keep your local sam-products-api folder. When you are ready to return, run sam build then sam deploy to recreate everything.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes both cloud and local resources permanently.
- Open PowerShell and navigate to your sam-products-api folder on the Desktop.
- Delete the CloudFormation stack and all deployment artifacts by running:
sam delete
- When prompted, confirm you want to delete the stack and the S3 template file by entering y for each prompt. This removes your Lambda functions, DynamoDB tables, HTTP API, Cognito User Pool, and all IAM roles.
- Delete your local project folder by running:
cd ~/Desktop
Remove-Item -Recurse -Force sam-products-api
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see sam-products-api in the output.
Nice Work!
Nice Work!
You just built and secured a production-ready serverless API using nothing but a single YAML file and a few CLI commands. Your entire backend, from database to authentication, lives in code you can version, share, and redeploy at will.
Here's what you accomplished:
- Defined a complete serverless API (Lambda + DynamoDB + API Gateway) in a single SAM template and deployed it with one command.
- Added Cognito User Pool authentication to the template so the API rejects unauthenticated requests with a 401 Unauthorized response.
- Proved the full auth flow end-to-end: signed up a user, obtained a JWT, and called the protected endpoint successfully.
- Secret Mission: Extended the template with a second protected Orders microservice endpoint, proving that scaling IaC is just more YAML.
Ready to quiz yourself?