Building Secure Serverless Apps: AWS Cognito, Lambda, and DynamoDB
A hands-on guide to implementing authentication, authorization, and data protection in a serverless restaurant discovery app
Introduction
I recently completed a security challenge building the Halal Finder App — a serverless application that helps users discover halal restaurants near them using real-time GPS location-based search.
But here’s the thing: A great app without security is a liability.
So I added three critical security layers:
- Cognito — Authentication (Who are you?)
- Lambda Authorization — Authorization (What can you do?)
- DynamoDB — Secure data storage (Keep data safe)
This is my journey implementing CIA Triad security in serverless architecture.
The Problem We’re Solving
Imagine a restaurant owner wants to update their Halal Finder listing:
- ❌ Without security: Anyone could change ANY restaurant’s data
- ✅ With security: Only the owner can update THEIR restaurant
This requires three layers:
- Prove who you are (Cognito)
- Check what you’re allowed to do (Lambda)
- Keep data protected (DynamoDB)
Part 1: Cognito Authentication
What is Cognito?
AWS Cognito is a managed authentication service. Think of it as:
- A database of users
- Password hashing built-in
- JWT token generation
- MFA support
- Zero infrastructure to maintain
The Problem We Hit
I initially tried to call Cognito directly from the browser:
// ❌ This fails because Cognito requires SECRET_HASH
const response = await cognitoClient.initiateAuth({
ClientId: clientId,
AuthFlow: 'USER_PASSWORD_AUTH',
AuthParameters: {
USERNAME: email,
PASSWORD: password,
SECRET_HASH: calculateSecretHash(...) // Browser can't do this safely!
}
});Why it failed: Cognito client secrets can’t be safely stored in browser code. If anyone inspects your JavaScript, they see the secret!
The Solution: Server-Side Login Route
Instead, I created a Next.js API route that runs on the server:
File: /app/api/login/route.ts
import { CognitoIdentityProviderClient, InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";const cognitoClient = new CognitoIdentityProviderClient({ region: "us-west-1" });export async function POST(request: Request) {
const { email, password } = await request.json(); try {
const command = new InitiateAuthCommand({
ClientId: process.env.COGNITO_CLIENT_ID!, // Public client (no secret!)
AuthFlow: "USER_PASSWORD_AUTH",
AuthParameters: {
USERNAME: email,
PASSWORD: password,
},
}); const response = await cognitoClient.send(command);
return Response.json({
token: response.AuthenticationResult?.IdToken,
accessToken: response.AuthenticationResult?.AccessToken,
});
} catch (error) {
return Response.json(
{ error: "Authentication failed" },
{ status: 401 }
);
}
}
Why this works:
- ✅ Server handles Cognito communication
- ✅ Public client (no SECRET_HASH needed)
- ✅ Browser never sees credentials
- ✅ Browser gets JWT token back
Key Cognito Concepts
User Pool: Database of users managed by AWS
User Pool: HalalFinderUsers (us-west-1_Bj3evTSoB)
├── Test User: fatuma@example.com
├── Password: TestPassword123!
└── JWT Token: {email: "fatuma@example.com", sub: "user-id"}JWT Token Structure:
{
"header": { "alg": "HS256", "type": "JWT" },
"payload": {
"sub": "us-west-1_Bj3evTSoB_abc123",
"email": "fatuma@example.com",
"email_verified": true,
"iss": "https://cognito-idp.us-west-1.amazonaws.com/us-west-1_Bj3evTSoB"
},
"signature": "encrypted-signature"
}Part 2: Lambda Authorization
What is Lambda Authorization?
A Lambda function sits between your frontend and database. It:
- Receives requests with JWT tokens
- Validates the JWT
- Checks if user owns the resource
- Allows or denies access
The Authorization Flow
Restaurant Owner
↓
Browser with JWT Token
↓
Lambda Function
├─ Decode JWT → Get user email
├─ Query DynamoDB → Get restaurant owner
├─ Compare: user email === restaurant owner?
└─ Return: 200 OK or 403 Forbidden
↓
DynamoDB (only if authorized)The Lambda Code
File: lambda/index.mjs
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";const client = new DynamoDBClient({ region: "us-west-1" });
const dynamodb = DynamoDBDocumentClient.from(client);export const handler = async (event) => {
try {
// Step 1: Extract JWT from Authorization header
const authHeader = event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'No authorization token' })
};
} // Step 2: Decode JWT (middle part is the payload)
const token = authHeader.replace('Bearer ', '');
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
const userEmail = payload.email; // Step 3: Get restaurant ID from URL
const restaurantId = event.pathParameters.id; // Step 4: Query DynamoDB for restaurant
const command = new GetCommand({
TableName: process.env.TABLE_NAME,
Key: { id: restaurantId }
}); const result = await dynamodb.send(command);
const restaurant = result.Item; if (!restaurant) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Restaurant not found' })
};
} // Step 5: Check ownership
if (restaurant.owner_email !== userEmail) {
return {
statusCode: 403,
body: JSON.stringify({ error: 'Not authorized to update this restaurant' })
};
} // Step 6: Authorization successful!
return {
statusCode: 200,
body: JSON.stringify({
message: 'Authorized!',
restaurant: restaurant
})
}; } catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};
Key Authorization Concepts
JWT Decoding (without external libraries):
const token = "eyJhbGc..."; // Received from Cognito
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// Now you have: { email: "fatuma@example.com", sub: "...", ... }Ownership Check:
// From JWT: userEmail = "fatuma@example.com"
// From DB: restaurant.owner_email = "fatuma@example.com"
// Result: ✅ Authorized!// If:
// From JWT: userEmail = "hacker@example.com"
// From DB: restaurant.owner_email = "fatuma@example.com"
// Result: ❌ Forbidden!Lambda Environment Variables:
TABLE_NAME=halal-finder-locations
KMS_KEY_ID=ef2a59b5-df6a-48f3-aca0-1a2d84fe4611Part 3: DynamoDB Secure Storage
What is DynamoDB?
DynamoDB is AWS’s serverless database:
- Pay-per-request (no idle costs!)
- Scales automatically
- Encrypted at rest (with KMS)
- No infrastructure to manage
Table Structure
Table: halal-finder-locationsPartition Key (Primary Key): id (String)
├── Item 1
│ ├── id: "1"
│ ├── name: "Kabob House"
│ ├── address: "123 Main St"
│ ├── owner_email: "fatuma@example.com"
│ └── address_encrypted: "[encrypted]"
│
├── Item 2
│ ├── id: "2"
│ ├── name: "Biriyani Palace"
│ ├── address: "456 Oak Ave"
│ ├── owner_email: "fatuma@example.com"
│ └── address_encrypted: "[encrypted]"Creating the Table
Via AWS Console:
- Go to DynamoDB
- Click “Create table”
- Table name:
halal-finder-locations - Partition key:
id(String type) - Billing mode: Pay-per-request
- Create!
Why This Structure Works
Partition Key (id):
- Uniquely identifies each restaurant
- DynamoDB uses it to find items FAST
- You query:
Get restaurant where id = "2" - DynamoDB responds instantly (not sequential scan!)
Owner Email:
- Stored in plaintext (for Lambda ownership check)
- Could be encrypted separately for extra security
Address Encrypted:
- Can be encrypted with KMS
- Only decrypted when needed
Security Pattern: CIA Triad
This architecture implements all three pillars of security:
Confidentiality ✅
“Can only authorized people see the data?”
- JWT tokens prove identity (Cognito)
- Ownership checks prevent unauthorized access (Lambda)
- Data encrypted at rest (KMS)
Integrity ✅
“Can we detect if data was modified?”
- Only owner can modify their restaurant
- Audit trails track changes (CloudTrail)
- DynamoDB versioning available
Availability ✅
“Is the data accessible when needed?”
- DynamoDB scales automatically
- No single point of failure
- Point-in-time recovery enabled
Real Test Results
Here’s what happens when someone tries to update a restaurant:
Get Fatuma Abdo’s stories in your inbox
Join Medium for free to get updates from this writer.
Scenario 1: Owner updating their restaurant ✅
POST /restaurant/2 HTTP/1.1
Authorization: Bearer eyJhbGc...payload-with-fatuma@example.comResponse: 200 OK
{
"message": "Authorized!",
"restaurant": {
"id": "2",
"name": "Biriyani Palace",
"owner_email": "fatuma@example.com"
}
}Scenario 2: Hacker trying to update someone else’s restaurant ❌
POST /restaurant/2 HTTP/1.1
Authorization: Bearer eyJhbGc...payload-with-hacker@example.comResponse: 403 Forbidden
{
"error": "Not authorized to update this restaurant"
}Lessons Learned
1. Never Put Secrets in Browser Code
❌ Client secrets in JavaScript = Public knowledge ✅ Use server-side API routes for Cognito
2. JWT Tokens Are Verifiable, Not Secret
- Anyone can READ a JWT (it’s base64)
- Only AWS can CREATE a valid JWT (with secret signature)
- So decoding in Lambda is safe!
3. Authorization Requires TWO Steps
- Authentication: Verify who you are (Cognito)
- Authorization: Verify what you can do (Lambda ownership check)
- Both are needed for security!
4. Environment Variables Keep Secrets Safe
// ✅ Safe: Secret in environment variable
const tableId = process.env.TABLE_NAME;// ❌ Unsafe: Secret in code
const tableId = "halal-finder-locations";5. DynamoDB Partition Keys Matter
- Partition key = primary way to find items
- Design your key based on query patterns
- Wrong key design = slow queries
What’s Next?
This implementation covers Cognito, Lambda, and DynamoDB. The full security project also includes:
- KMS Encryption: Encrypt sensitive data before storing
- CloudTrail Logging: Audit trail of who did what and when
- Risk Assessment: Document remaining risks and mitigations
Links & Resources
GitHub Repositories:
- Main project: github.com/fatumaabdo14-cmd/halal_finder-
- Security CDK: github.com/fatumaabdo14-cmd/halal-finder-security-cdk
Live Demo:
- Halal Finder App: https://d1e3ukex7mhofw.cloudfront.net
AWS Documentation:
Final Thoughts
Building secure serverless apps requires thinking like a security engineer:
- Every request needs authentication
- Every action needs authorization
- Every database needs protection
Cognito, Lambda, and DynamoDB together provide a foundation for secure, scalable applications.
The code is open source — fork it, learn from it, and build your own secure apps!
This project was completed as part of hands-on cloud security learning. All code is production-ready and follows AWS best practices.









