MediumBuilding Secure Serverless Apps: AWS Cognito, Lambda, and DynamoDB | by Fatuma Abdo | Sep, 2026 | Medium
Sitemap
Fatuma Abdo

Fatuma Abdo

AWS Cloud Engineer | Linux | CI/CD | AI | Passionate About Cloud Automation☁️

Building Secure Serverless Apps: AWS Cognito, Lambda, and DynamoDB

6 min readSep 25, 2026

--

A hands-on guide to implementing authentication, authorization, and data protection in a serverless restaurant discovery app

Press enter or click to view image in full size

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:

  1. Cognito — Authentication (Who are you?)
  2. Lambda Authorization — Authorization (What can you do?)
  3. 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:

  1. Prove who you are (Cognito)
  2. Check what you’re allowed to do (Lambda)
  3. 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:

  1. Receives requests with JWT tokens
  2. Validates the JWT
  3. Checks if user owns the resource
  4. 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-1a2d84fe4611

Part 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-locations
Partition 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:

  1. Go to DynamoDB
  2. Click “Create table”
  3. Table name: halal-finder-locations
  4. Partition key: id (String type)
  5. Billing mode: Pay-per-request
  6. 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.com
Response: 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.com
Response: 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

  1. Authentication: Verify who you are (Cognito)
  2. 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

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.

--

--

Written by Fatuma Abdo

AWS Cloud Engineer | Linux | CI/CD | AI | Passionate About Cloud Automation☁️