Serverless architecture is transforming the way modern applications are built, offering scalability, cost-efficiency, and freedom from managing servers. By using services like AWS Lambda, API Gateway, Cognito, and DynamoDB, developers can focus on writing code while AWS handles the infrastructure. In this post, we’ll walk you through building a serverless full-stack application using AWS CDK and React, showing how these services work together to create a seamless and secure user experience.
This is more than a collection of code snippets—it’s a detailed guide where you’ll understand the why behind every step. Some code will be repeated with slight variations to help you fully grasp how each piece fits into the bigger picture. By following along, you’ll not only learn to set up Cognito authentication and secure your API calls with Bearer tokens, but also understand how to build scalable, secure applications with AWS’s serverless tools.
By the end of this post, you’ll have built a fully serverless application that authenticates users, secures APIs, and retrieves data—all without managing a server. This knowledge will empower you to leverage AWS’s serverless ecosystem and build ambitious, scalable applications. Let’s get started!
- Application Architecture Overview
- Step 1: Creating an S3 Public Website, Building a React App, and Deploying it via AWS CDK
- Step 2: Creating a Lambda Function with API Gateway and Connecting It to React App
- Step 3: Adding DynamoDB with Add, Update, Delete Functionality to Lambda and Integrating It with the React App
- Step 4: Integrating Cognito Authentication with API Gateway, Lambda, and React using CDK
- Conclusion
Application Architecture Overview
In this tutorial, we will build a full-stack serverless application using a combination of powerful AWS services. The architecture is designed to be highly scalable, cost-efficient, and fully managed, allowing you to focus on your application’s functionality without worrying about infrastructure management.
Here’s a breakdown of the key AWS services used in this architecture:
1. AWS Lambda (Backend Logic)
AWS Lambda is the backbone of our serverless application. It allows you to run your backend code without provisioning or managing servers. Lambda functions are event-driven and execute in response to various triggers, such as HTTP requests or updates to the database.
- Role in the architecture: AWS Lambda will handle the business logic for our application, processing API requests and interacting with the database. It allows you to build scalable applications where you pay only for the compute time used.
- Key benefit: Serverless execution with automatic scaling, high availability, and built-in fault tolerance.
2. Amazon API Gateway (HTTP API Handling)
Amazon API Gateway is a fully managed service that allows developers to create, publish, maintain, monitor, and secure APIs at scale. It acts as a gateway for external clients (such as our frontend application) to interact with the backend.
- Role in the architecture: API Gateway will provide the HTTP interface for our Lambda functions. It will handle the routing of incoming requests from the React frontend to the appropriate backend Lambda function.
- Key benefit: It offers a scalable and highly available solution to manage your API traffic with built-in features like request throttling, caching, and security.
3. Amazon DynamoDB (NoSQL Database)
Amazon DynamoDB is a fully managed NoSQL database service designed to deliver fast and predictable performance at any scale. It is ideal for serverless applications due to its ability to automatically scale to handle any amount of traffic.
- Role in the architecture: DynamoDB will serve as the database for our application, storing key-value data with high availability. The application will use DynamoDB to store and retrieve user data or other relevant information.
- Key benefit: Fully managed, serverless, and automatically scalable database service with low-latency performance.
4. Amazon Cognito (User Authentication and Authorization)
Amazon Cognito is a powerful service that provides user sign-up, sign-in, and access control, allowing you to easily add user authentication to your web and mobile applications.
- Role in the architecture: Cognito will manage user authentication and authorization for our application, ensuring that only authenticated users can access certain parts of the app. It simplifies the process of adding user management to our serverless application.
- Key benefit: Secure and scalable user authentication, with built-in user pools and integration with AWS services like API Gateway.
5. React SPA (Frontend)
We will use React to build the frontend of the application. React is a popular JavaScript library for building fast, responsive user interfaces.
- Role in the architecture: The React SPA will serve as the client-side application, allowing users to interact with the backend through a modern, responsive interface. It will be integrated with Cognito for authentication and API Gateway for communicating with Lambda.
- Key benefit: A dynamic and responsive frontend that enables users to interact with the serverless backend and manage their authentication easily.
Application Workflow
- User Authentication: Users sign up or log in via the React SPA, which uses Amazon Cognito for secure user management. Cognito handles user authentication and returns a JSON Web Token (JWT) upon successful login.
- Frontend Interaction: Once authenticated, users can interact with the React app. The app will make authenticated API calls to the AWS API Gateway, using the JWT from Cognito to authorize requests.
- API Requests: API Gateway routes the user’s requests to the appropriate Lambda function based on the API endpoint. The API Gateway is configured with Cognito User Pools as the authorizer, ensuring that only authenticated users can interact with the backend.
- Backend Processing: AWS Lambda handles the business logic for the requests. For example, it can process user inputs, retrieve data from DynamoDB, or update records in the database.
- Data Storage: DynamoDB stores the application data, such as user profiles, order details, or any other relevant information. Lambda functions interact with DynamoDB using the AWS SDK to read from or write to the database as needed.
- Response to Frontend: After processing the request, Lambda sends the response back to API Gateway, which then returns the appropriate response (such as success or error messages) to the React frontend. The frontend displays this data to the user.
Visual Diagram of the Architecture
Here’s a visual breakdown of how the architecture fits together:
This architecture creates a fully serverless full-stack application where all components scale automatically to meet demand, making it both cost-effective and highly efficient. It allows you to focus on writing code while AWS handles the heavy lifting for infrastructure management, security, and scalability.
By the end of this tutorial, you’ll have an understanding of how to build such a solution from scratch using AWS CDK, leveraging each of these services to create a robust and secure full-stack serverless application.
Step 1: Creating an S3 Public Website, Building a React App, and Deploying it via AWS CDK
In this section, we’ll walk through the process of creating an S3 bucket for hosting a public website, building a React application, and deploying the frontend to the S3 bucket using AWS CDK. This will automate the deployment process for your frontend application and allow you to host it publicly on AWS S3.
Set Up AWS CDK
First, you need to initialize your CDK project if it’s not already created. Install the AWS CDK globally (if you haven’t already):
npm install -g aws-cdk
Next, create your CDK project directory:
mkdir serverless-fullstack-app
cd serverless-fullstack-app
cdk init app --language typescript
This will create the base AWS CDK project structure.
Create an S3 Bucket for Hosting the React App
We will define an S3 bucket in the CDK project where the React app will be hosted. We will also make sure that the bucket is publicly accessible for static website hosting.
- Install necessary CDK packages: To manage S3 buckets and deploy files to them, install the necessary packages:
npm install @aws-cdk/aws-s3 @aws-cdk/aws-s3-deployment
- Create the CDK Stack to define the S3 bucket: Open the file
lib/serverless-fullstack-app-stack.ts
and define the S3 bucket as follows:
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
export class ServerlessFullstackAppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create an S3 bucket to host the React app
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
websiteIndexDocument: 'index.html', // Entry point for the React app
websiteErrorDocument: 'index.html', // Handle routing by pointing all errors to index.html
publicReadAccess: true, // Make the bucket publicly accessible
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, // Allow bucket-level public access
removalPolicy: cdk.RemovalPolicy.DESTROY, // Automatically clean up the bucket on stack deletion
autoDeleteObjects: true, // Delete all objects in the bucket when the stack is deleted
});
new cdk.CfnOutput(this, 'BucketWebsiteURL', {
value: websiteBucket.bucketWebsiteUrl, // Output the S3 website URL
description: 'URL of the static website hosted on S3',
});
}
}
This code creates a public S3 bucket that will host the static files for your React application.
Create the React App
Now that we have defined the S3 bucket, let’s create the React app that will be deployed to it.
- Create the React App: In the root directory of your project (outside the CDK folder), run the following command to create a React app:
npx create-react-app my-app
cd my-app
- Build the React App: Once you’ve created the React app and made any necessary changes to your app (optional), build it to prepare for deployment:
npm run build
This command will create a build/
directory that contains the production-ready static files for your React app.
Deploy the React App to the S3 Bucket
Now that both the CDK stack and React app are ready, we’ll use the aws-s3-deployment
package to deploy the React app’s static files to the S3 bucket.
- Modify the CDK stack to deploy the React app: Go back to your
lib/serverless-fullstack-app-stack.ts
file, and add the following code to deploy the React app’sbuild/
folder to the S3 bucket during CDK deployment:
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; // Import s3-deployment
// ... existing code ...
// Deploy the React app's build folder to the S3 bucket
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset('./my-app/build')], // Path to the React app's build folder
destinationBucket: websiteBucket,
});
This will automatically upload the contents of the build/
folder to the S3 bucket during the cdk deploy
process.
Deploy the CDK Stack
Once everything is set up, you can deploy the stack using CDK. This will create the S3 bucket and deploy the React app’s static files to the bucket.
- Build the React app again (if you haven’t):
cd my-app
npm run build
- Deploy the CDK stack: Now, navigate back to your CDK project directory and run the following command to deploy the stack:
cdk deploy
CDK will:
- Create the S3 bucket (if it doesn’t exist).
- Deploy the React app’s static files from the
build/
folder into the bucket.
- Check the output: Once the deployment is complete, CDK will output the S3 website URL in the terminal. You can access the React app by visiting this URL:
http://your-bucket-name.s3-website-region.amazonaws.com
Verify Your Deployment
Open the URL output by the CDK deployment, and you should see your React app hosted publicly on S3.
Summary of Public Website in S3 and React App Step
In this step, we:
- Created an S3 bucket for static website hosting using AWS CDK.
- Built a React app and prepared it for deployment.
- Used the
aws-s3-deployment
package to automatically deploy the React app’s static files to the S3 bucket during the CDK deployment. - Deployed the entire infrastructure and frontend using a single
cdk deploy
command.
Step 2: Creating a Lambda Function with API Gateway and Connecting It to React App
In this guide, we will:
- Create a Lambda function using AWS CDK.
- Set up an API Gateway that triggers the Lambda function.
- Update the React app to call the API Gateway, which triggers the Lambda function and returns static content.
Create the Lambda Function and API Gateway
- Install necessary AWS CDK packages:
Ensure you have installed the required packages to create Lambda functions and API Gateway endpoints.
npm install @aws-cdk/aws-lambda @aws-cdk/aws-apigateway
- Modify the CDK Stack to Create Lambda and API Gateway: Open the
lib/serverless-fullstack-app-stack.ts
file and modify it to add the Lambda function and API Gateway setup:
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
export class ServerlessFullstackAppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create an S3 bucket to host the React app
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
websiteIndexDocument: 'index.html', // Entry point for the React app
websiteErrorDocument: 'index.html', // Handle routing by pointing all errors to index.html
publicReadAccess: true, // Make the bucket publicly accessible
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, // Allow bucket-level public access
removalPolicy: cdk.RemovalPolicy.DESTROY, // Automatically clean up the bucket on stack deletion
autoDeleteObjects: true, // Delete all objects in the bucket when the stack is deleted
});
// Deploy the React app's build folder to the S3 bucket
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset('./my-app/build')], // Path to the React app's build folder
destinationBucket: websiteBucket,
});
new cdk.CfnOutput(this, 'BucketWebsiteURL', {
value: websiteBucket.bucketWebsiteUrl, // Output the S3 website URL
description: 'URL of the static website hosted on S3',
});
// Create a Lambda function that returns static content
const apiLambda = new lambda.Function(this, 'ApiLambda', {
runtime: lambda.Runtime.NODEJS_LATEST, // Lambda runtime environment
code: lambda.Code.fromAsset('lambda'), // Path to Lambda function code
handler: 'handler.main', // Lambda function handler
});
// API Gateway to expose the Lambda function
const api = new apigateway.LambdaRestApi(this, 'ApiEndpoint', {
handler: apiLambda,
proxy: false,
});
// Define a GET resource
const staticContentResource = api.root.addResource('static-content');
staticContentResource.addMethod('GET', new apigateway.LambdaIntegration(apiLambda));
}
}
- Lambda Function: This code sets up a Lambda function that serves static content.
- API Gateway: An API Gateway (
/static-content
route) is created to trigger the Lambda function when a GET request is made.
- Create the Lambda Function Code: In the root of your CDK project (at the same level as
lib
), create a folder namedlambda
and inside it, create a file namedhandler.js
. This file will contain the Lambda function code:
// lambda/handler.js
exports.main = async function(event, context) {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*', // Allow all origins
'Access-Control-Allow-Headers': '*', // Allow all headers
},
body: JSON.stringify({
message: "Hello from Lambda! Your static content is served with CORS enabled.",
}),
};
};
This Lambda function returns a simple JSON object indicating that the response is coming from the Lambda function.
The CORS headers added to the Lambda function (Access-Control-Allow-Origin
and Access-Control-Allow-Headers
) are necessary to allow the frontend, hosted on a different domain, to communicate with the API. Without these headers, browsers would block cross-origin requests due to security policies. By including these headers, we ensure that the API can be accessed by the React app, resolving CORS issues and enabling smooth communication between the frontend and backend.
Modify the React App to Call the API Gateway
Now that the API Gateway is set up, we’ll modify the React app to call the API endpoint.
- Update
App.js
in React App: In themy-app/src/App.js
file, modify it to make an HTTP request to the API Gateway and display the response:
import React, { useState, useEffect } from 'react';
import './App.css';
function App() {
const [apiResponse, setApiResponse] = useState('');
useEffect(() => {
// Replace 'your-api-id' and 'region' with your actual API Gateway details
fetch('https://your-api-id.execute-api.region.amazonaws.com/prod/static-content')
.then((response) => response.json())
.then((data) => setApiResponse(data.message))
.catch((error) => console.error('Error fetching API:', error));
}, []);
return (
<div className="App">
<header className="App-header">
<h1>Serverless React App</h1>
<p>{apiResponse ? `API Response: ${apiResponse}` : 'Loading API response...'}</p>
</header>
</div>
);
}
export default App;
- This code makes a
GET
request to the API Gateway at the/static-content
endpoint. - Replace
your-api-id
andregion
with the actual values from the API Gateway URL that will be output after the deployment.
Deploy the Stack
- Build the React App: Build the React app before deploying the stack:
cd my-app
npm run build
This generates the production-ready static files in the build/
directory.
- Deploy the CDK Stack: Now, navigate back to the root of your CDK project and deploy the stack:
cdk deploy
The deployment process will:
- Create the S3 bucket and deploy the React app.
- Deploy the Lambda function.
- Set up the API Gateway that connects to the Lambda function.
- Get the API Gateway URL: Once the stack is deployed, the API Gateway URL will be output in the terminal. It will look something like:
https://your-api-id.execute-api.region.amazonaws.com/prod/static-content
Test the Application
- Access the React App: Open the S3 URL (output by CDK) in your browser. This is the public website hosting the React app.
- See the API in Action: When the React app loads, it will make a request to the API Gateway, which will trigger the Lambda function. The response from the Lambda function (e.g.,
"Hello from Lambda! Your static content is served."
) will be displayed in the React app.
Summary of Creating the Lambda Function and API Gateway
In this step, we:
- Set up an S3 bucket to host a React app using AWS CDK.
- Created a Lambda function that returns static content.
- Created an API Gateway that triggers the Lambda function when a GET request is made.
- Updated the React app to call the API and display the response.
- Deployed everything using AWS CDK.
This architecture allows you to build a fully serverless application, with a React frontend hosted on S3 and a backend powered by Lambda and API Gateway. Let me know if you need further clarifications!
Step 3: Adding DynamoDB with Add, Update, Delete Functionality to Lambda and Integrating It with the React App
In this guide, we’ll expand the functionality of our serverless application by adding DynamoDB as a data store for a simple to-do list. We will:
- Set up a DynamoDB table to store to-do items.
- Update the Lambda function to handle add, update, and delete operations on to-do items.
- Modify the React app to allow users to interact with the to-do list via the API Gateway.
Create the DynamoDB Table
We will first define a DynamoDB table to store the to-do items.
- Modify the CDK Stack to Create a DynamoDB Table:
In yourlib/serverless-fullstack-app-stack.ts
, add the DynamoDB table definition:
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
export class ServerlessFullstackAppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create an S3 bucket to host the React app
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
websiteIndexDocument: 'index.html', // Entry point for the React app
websiteErrorDocument: 'index.html', // Handle routing by pointing all errors to index.html
publicReadAccess: true, // Make the bucket publicly accessible
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, // Allow bucket-level public access
removalPolicy: cdk.RemovalPolicy.DESTROY, // Automatically clean up the bucket on stack deletion
autoDeleteObjects: true, // Delete all objects in the bucket when the stack is deleted
});
// Deploy the React app's build folder to the S3 bucket
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset('./my-app/build')], // Path to the React app's build folder
destinationBucket: websiteBucket,
});
new cdk.CfnOutput(this, 'BucketWebsiteURL', {
value: websiteBucket.bucketWebsiteUrl, // Output the S3 website URL
description: 'URL of the static website hosted on S3',
});
// Create DynamoDB table for to-do items
const todoTable = new dynamodb.Table(this, 'TodoTable', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Create a Lambda function that returns static content
const apiLambda = new lambda.Function(this, 'ApiLambda', {
runtime: lambda.Runtime.NODEJS_LATEST, // Lambda runtime environment
code: lambda.Code.fromAsset('lambda'), // Path to Lambda function code
handler: 'handler.main', // Lambda function handler
environment: {
TODO_TABLE_NAME: todoTable.tableName, // Pass table name to the Lambda function
},
});
// Grant the Lambda function permissions to read/write to the DynamoDB table
todoTable.grantReadWriteData(apiLambda);
// API Gateway to expose the Lambda function
const api = new apigateway.LambdaRestApi(this, 'ApiEndpoint', {
handler: apiLambda,
proxy: false,
});
// Define resource for to-do items
const todoResource = api.root.addResource('todos');
todoResource.addMethod('POST', ); // Create to-do item
todoResource.addMethod('PUT'); // Update to-do item
todoResource.addMethod('DELETE'); // Delete to-do item
todoResource.addMethod('GET'); // Get list of to-do items
todoResource.addMethod('OPTIONS'); // Options method for CORS
}
}
Update the Lambda Function for CRUD Operations
Now, we will modify the Lambda function to handle create, update, delete, and retrieve operations for to-do items stored in DynamoDB.
- Update
lambda/handler.js
to Handle CRUD Operations:
// lambda/handler.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, PutCommand, GetCommand, ScanCommand, DeleteCommand, UpdateCommand } = require('@aws-sdk/lib-dynamodb');
// Initialize the DynamoDB client
const ddbClient = new DynamoDBClient();
// Create the DynamoDBDocumentClient to simplify interactions
const dynamoDb = DynamoDBDocumentClient.from(ddbClient);
// Define the CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'POST, PUT, DELETE, GET, OPTIONS'
};
exports.main = async function (event, context) {
try {
const httpMethod = event.httpMethod;
let body;
if (event.body) {
body = JSON.parse(event.body);
}
switch (httpMethod) {
case 'POST': // Create a to-do item
const newItem = {
id: body.id,
task: body.task,
completed: body.completed || false,
};
await dynamoDb.send(new PutCommand({
TableName: process.env.TODO_TABLE_NAME,
Item: newItem,
}));
return {
statusCode: 201,
headers: corsHeaders,
body: JSON.stringify({ message: 'To-do item created', item: newItem }),
};
case 'PUT': // Update a to-do item
await dynamoDb.send(new UpdateCommand({
TableName: process.env.TODO_TABLE_NAME,
Key: { id: body.id },
UpdateExpression: 'set task = :task, completed = :completed',
ExpressionAttributeValues: {
':task': body.task,
':completed': body.completed,
},
}));
return {
statusCode: 200,
headers: corsHeaders,
body: JSON.stringify({ message: 'To-do item updated' }),
};
case 'DELETE': // Delete a to-do item
await dynamoDb.send(new DeleteCommand({
TableName: process.env.TODO_TABLE_NAME,
Key: { id: body.id },
}));
return {
statusCode: 200,
headers: corsHeaders,
body: JSON.stringify({ message: 'To-do item deleted' }),
};
case 'GET': // Retrieve all to-do items
const data = await dynamoDb.send(new ScanCommand({ TableName: process.env.TODO_TABLE_NAME }));
return {
statusCode: 200,
headers: corsHeaders,
body: JSON.stringify({ items: data.Items }),
};
case 'OPTIONS': // Handle preflight requests
return {
statusCode: 200,
headers: corsHeaders
};
default:
return {
statusCode: 405,
headers: corsHeaders,
body: JSON.stringify({ message: `Unsupported method ${httpMethod}` }),
};
}
} catch (error) {
console.error('Error processing request:', error);
return {
statusCode: 500,
headers: corsHeaders,
body: JSON.stringify({ message: 'Internal Server Error' }),
};
}
};
- POST: Creates a new to-do item with
id
,task
, andcompleted
status. - PUT: Updates an existing to-do item.
- DELETE: Deletes a to-do item by
id
. - GET: Retrieves the list of all to-do items.
Modify the React App to Interact with the API
We will now update the React app to allow users to add, update, delete, and view to-do items using the API.
- Update
App.js
to Add CRUD Functionality:
import React, { useState, useEffect } from 'react';
import './App.css';
function App() {
const [todos, setTodos] = useState([]);
const [task, setTask] = useState('');
useEffect(() => {
// Fetch all to-do items
fetch('https://your-api-id.execute-api.region.amazonaws.com/prod/todos')
.then((response) => response.json())
.then((data) => setTodos(data.items))
.catch((error) => console.error('Error fetching to-dos:', error));
}, []);
const addTodo = () => {
const newTodo = { id: Date.now().toString(), task, completed: false };
fetch('https://your-api-id.execute-api.region.amazonaws.com/prod/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
.then((response) => response.json())
.then((data) => {
setTodos([...todos, newTodo]);
setTask('');
})
.catch((error) => console.error('Error adding to-do:', error));
};
const deleteTodo = (id) => {
fetch('https://your-api-id.execute-api.region.amazonaws.com/prod/todos', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
.then((response) => response.json())
.then(() => {
setTodos(todos.filter((todo) => todo.id !== id));
})
.catch((error) => console.error('Error deleting to-do:', error));
};
return (
<div className="App">
<header className="App-header">
<h1>To-Do List</h1>
<input
type="text"
placeholder="Add a task"
value={task}
onChange={(e) => setTask(e.target.value)}
/>
<button onClick={addTodo}>Add To-Do</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.task} <button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</header>
</div>
);
}
export default App;
- Add a To-Do: Allows the user to add a to-do by sending a
POST
request to the API. - Delete a To-Do: Deletes a to-do by sending a
DELETE
request to the API. - Fetch To-Dos: Fetches and displays the list of to-do items.
Step 4: Integrating Cognito Authentication with API Gateway, Lambda, and React using CDK
In this extended guide, we will walk through integrating AWS Cognito for authentication with API Gateway and Lambda using AWS CDK. We will cover the CDK updates needed to create a Cognito User Pool, protect API Gateway resources with authentication, and connect everything to a React frontend using Bearer token authentication.
Key Goals:
- Set up AWS Cognito for password-based authentication and self-sign-up using CDK.
- Create an API Gateway with Cognito Authorization using CDK.
- Protect API Gateway routes that connect to Lambda functions.
- Implement Bearer token authentication in the React app for secure API calls.
- Handle CORS for unauthenticated requests like OPTIONS.
Update CDK to Create a Cognito User Pool
The first step is to define a Cognito User Pool in CDK with password-based authentication and allow users to self-sign-up.
CDK Code for Cognito User Pool
Here’s the CDK code after adding a Cognito User Pool and a User Pool Client that supports password-based authentication:
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
export class ServerlessFullstackAppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create an S3 bucket to host the React app
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
websiteIndexDocument: 'index.html', // Entry point for the React app
websiteErrorDocument: 'index.html', // Handle routing by pointing all errors to index.html
publicReadAccess: true, // Make the bucket publicly accessible
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, // Allow bucket-level public access
removalPolicy: cdk.RemovalPolicy.DESTROY, // Automatically clean up the bucket on stack deletion
autoDeleteObjects: true, // Delete all objects in the bucket when the stack is deleted
});
// Deploy the React app's build folder to the S3 bucket
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset('./my-app/build')], // Path to the React app's build folder
destinationBucket: websiteBucket,
});
new cdk.CfnOutput(this, 'BucketWebsiteURL', {
value: websiteBucket.bucketWebsiteUrl, // Output the S3 website URL
description: 'URL of the static website hosted on S3',
});
// Create a Cognito User Pool
const userPool = new cognito.UserPool(this, 'UserPool', {
signInAliases: { email: true }, // Users will sign in with email
selfSignUpEnabled: true, // Allow users to sign up
});
// Create a Cognito User Pool Client
const userPoolClient = new cognito.UserPoolClient(this, 'UserPoolClient', {
userPool,
authFlows: { userPassword: true }, // Enable username/password-based authentication
});
// Create DynamoDB table for to-do items
const todoTable = new dynamodb.Table(this, 'TodoTable', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Create a Lambda function that returns static content
const apiLambda = new lambda.Function(this, 'ApiLambda', {
runtime: lambda.Runtime.NODEJS_LATEST, // Lambda runtime environment
code: lambda.Code.fromAsset('lambda'), // Path to Lambda function code
handler: 'handler.main', // Lambda function handler
environment: {
TODO_TABLE_NAME: todoTable.tableName, // Pass table name to the Lambda function
},
});
// Grant the Lambda function permissions to read/write to the DynamoDB table
todoTable.grantReadWriteData(apiLambda);
var lambdaIntegration = new apigateway.LambdaIntegration(apiLambda);
// API Gateway to expose the Lambda function
const api = new apigateway.LambdaRestApi(this, 'ApiEndpoint', {
handler: apiLambda,
proxy: false,
});
// Cognito authorizer for API Gateway
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'CognitoAuthorizer', {
cognitoUserPools: [userPool]
});
var todoResourceOptions = {
authorizer, // Require Cognito authentication
authorizationType: apigateway.AuthorizationType.COGNITO,
};
// Define resource for to-do items
const todoResource = api.root.addResource('todos');
todoResource.addMethod('POST', lambdaIntegration, todoResourceOptions); // Create to-do item
todoResource.addMethod('PUT', lambdaIntegration, todoResourceOptions); // Update to-do item
todoResource.addMethod('DELETE', lambdaIntegration, todoResourceOptions); // Delete to-do item
todoResource.addMethod('GET', lambdaIntegration, todoResourceOptions);
todoResource.addMethod('OPTIONS', lambdaIntegration); // Options method for CORS support withouth authentication
}
}
CDK Highlights:
1- User Pool with Self Sign-Up:
- Enabled
selfSignUpEnabled: true
to allow users to sign up on their own.
2- Password-Based Authentication:
- Set
authFlows: { userPassword: true }
in the User Pool Client to enable password authentication.
3- Cognito Authorization in API Gateway:
- Used
CognitoUserPoolsAuthorizer
to require Cognito authentication for the GET method of the/todos
API.
4- CORS Support for OPTIONS:
- Configured an unauthenticated OPTIONS method to allow cross-origin requests with the correct headers.
Enhancing the Frontend Structure for Cognito Authentication in React
In this section, we’ll enhance the structure of the React frontend by separating the Sign-Up, Sign-In, and To-Do List components. We’ll ensure that the UI shows and hides the proper components based on the authentication state of the user (whether they are signed in or not). We’ll also handle the toggling between the Sign-In and Sign-Up forms via user interaction.
Key Features:
- Sign-In and Sign-Up Forms: Separate components for signing up and signing in, controlled by the main
App
component. - Authentication State Management: Use of state to track whether the user is authenticated and has a valid token.
- Showing/Hiding Components: Proper UI rendering based on authentication state.
- Bearer Token Authentication: Using JWT token in Authorization headers for secured API requests.
Define the React App Structure
We will create the following components:
- SignUp.js: Handles user registration.
- SignIn.js: Handles user login.
- TodoList.js: Displays to-do items for authenticated users.
- App.js: Manages the overall application state, including switching between sign-up and sign-in forms and showing the to-do list after successful authentication.
App.js
– Main Component Managing Authentication State
The main component, App.js
, manages the authentication state and controls which components are displayed based on whether the user is authenticated.
- State management:
token
: Stores the JWT token after successful sign-in.showSignUp
: Controls whether to display the Sign-Up or Sign-In form.
Here’s the code for App.js
:
import React, { useState } from 'react';
import SignUp from './SignUp';
import SignIn from './SignIn';
import TodoList from './TodoList';
function App() {
const [token, setToken] = useState(null); // To store the JWT token
const [showSignUp, setShowSignUp] = useState(false); // To switch between sign-in and sign-up
// Callback to receive the token after authentication
const handleAuthentication = (jwtToken) => {
setToken(jwtToken);
console.log('JWT Token received:', jwtToken);
};
return (
<div className="App">
<header className="App-header">
<h1>To-Do App with Cognito Authentication</h1>
{/* If token exists, show the Todo List; otherwise, show auth forms */}
{!token ? (
<div>
<button onClick={() => setShowSignUp(false)}>Sign In</button>
<button onClick={() => setShowSignUp(true)}>Sign Up</button>
{showSignUp ? (
<SignUp onAuthenticated={handleAuthentication} />
) : (
<SignIn onAuthenticated={handleAuthentication} />
)}
</div>
) : (
<TodoList token={token} />
)}
</header>
</div>
);
}
export default App;
SignUp.js
– Handle User Registration
This component allows users to self-sign-up using AWS Cognito. Upon successful sign-up and confirmation, it passes the JWT token to the parent (App.js
) to authenticate the user.
Here’s the code for SignUp.js
:
import React, { useState } from 'react';
import { CognitoIdentityProviderClient, SignUpCommand, ConfirmSignUpCommand, InitiateAuthCommand } from '@aws-sdk/client-cognito-identity-provider';
const REGION = 'ap-southeast-2'; // e.g., 'us-east-1'
const CLIENT_ID = '5smu4ph3npsvq8om48mmg8912v'; // e.g., 'XXXXXX'
const client = new CognitoIdentityProviderClient({ region: REGION });
function SignUp({ onAuthenticated }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [confirmationCode, setConfirmationCode] = useState('');
const [isSignUpComplete, setIsSignUpComplete] = useState(false); // Track if the user has signed up
const [error, setError] = useState(null);
const [message, setMessage] = useState(null);
// Sign-up the user
const handleSignUp = async () => {
try {
const command = new SignUpCommand({
ClientId: CLIENT_ID,
Username: username,
Password: password,
UserAttributes: [
{
Name: 'email',
Value: email,
},
],
});
await client.send(command);
setMessage('Sign-up successful! Please check your email to verify your account.');
setError(null);
setIsSignUpComplete(true); // Move to the confirmation phase
} catch (err) {
setError('Sign-up failed. ' + err.message);
console.error('Sign-up error:', err);
}
};
// Confirm the user's sign-up
const handleConfirmSignUp = async () => {
try {
const confirmCommand = new ConfirmSignUpCommand({
ClientId: CLIENT_ID,
Username: username,
ConfirmationCode: confirmationCode,
});
await client.send(confirmCommand);
setMessage('Account confirmed! You can now sign in.');
setError(null);
// After confirming, sign the user in to get the token
const signInCommand = new InitiateAuthCommand({
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: CLIENT_ID,
AuthParameters: {
USERNAME: username,
PASSWORD: password,
},
});
const response = await client.send(signInCommand);
const token = response.AuthenticationResult.IdToken;
setMessage('Signed in successfully! Now you can access protected resources.');
onAuthenticated(token); // Pass the token to the parent component for future API calls
} catch (err) {
setError('Confirmation or Sign-in failed. ' + err.message);
console.error('Confirmation error:', err);
}
};
return (
<div>
<h1>{isSignUpComplete ? 'Confirm Sign-Up' : 'Sign Up'}</h1>
{!isSignUpComplete ? (
<div>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleSignUp}>Sign Up</button>
</div>
) : (
<div>
<p>Please enter the confirmation code sent to your email</p>
<input
type="text"
placeholder="Confirmation Code"
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value)}
/>
<button onClick={handleConfirmSignUp}>Confirm Sign-Up</button>
</div>
)}
{error && <p style={{ color: 'red' }}>{error}</p>}
{message && <p style={{ color: 'green' }}>{message}</p>}
</div>
);
}
export default SignUp;
SignIn.js
– Handle User Login
This component handles user sign-in. When the user successfully signs in, the JWT token is passed back to App.js
to authenticate the user.
Here’s the code for SignIn.js
:
import React, { useState } from 'react';
import { CognitoIdentityProviderClient, InitiateAuthCommand } from '@aws-sdk/client-cognito-identity-provider';
const REGION = 'ap-southeast-2'; // e.g., 'us-east-1'
const CLIENT_ID = '5smu4ph3npsvq8om48mmg8912v'; // e.g., 'XXXXXX'
const client = new CognitoIdentityProviderClient({ region: REGION });
function SignIn({ onAuthenticated }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const handleSignIn = async () => {
try {
const command = new InitiateAuthCommand({
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: CLIENT_ID,
AuthParameters: {
USERNAME: username,
PASSWORD: password,
},
});
const response = await client.send(command);
const token = response.AuthenticationResult.IdToken;
console.log('Sign-in successful. JWT Token:', token);
onAuthenticated(token); // Pass the token to the parent component
} catch (err) {
setError('Sign-in failed. ' + err.message);
console.error('Sign-in error:', err);
}
};
return (
<div>
<h1>Sign In</h1>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleSignIn}>Sign In</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
export default SignIn;
TodoList.js
– Display To-Do Items After Authentication
Once the user is authenticated, the TodoList
component will fetch the to-do items from the secured API using the JWT token for Bearer authentication.
Here’s the code for TodoList.js
:
import React, { useState, useEffect } from 'react';
import SignIn from './SignIn';
import SignUp from './SignUp';
import './App.css';
function TodoList({token}) {
const [todos, setTodos] = useState([]);
const [task, setTask] = useState('');
useEffect(async () => {
// Fetch all to-do items
fetch('https://8ys8xk6t49.execute-api.ap-southeast-2.amazonaws.com/prod/todos', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + token, // Use JWT token for Authorization
},
})
.then((response) => response.json())
.then((data) => setTodos(data.items))
.catch((error) => console.error('Error fetching to-dos:', error));
}, []);
const addTodo = () => {
const newTodo = { id: Date.now().toString(), task, completed: false };
fetch('https://8ys8xk6t49.execute-api.ap-southeast-2.amazonaws.com/prod/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token, // Use JWT token for Authorization
},
body: JSON.stringify(newTodo),
})
.then((response) => response.json())
.then((data) => {
setTodos([...todos, newTodo]);
setTask('');
})
.catch((error) => console.error('Error adding to-do:', error));
};
const deleteTodo = (id) => {
fetch('https://8ys8xk6t49.execute-api.ap-southeast-2.amazonaws.com/prod/todos', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token, // Use JWT token for Authorization
},
body: JSON.stringify({ id }),
})
.then((response) => response.json())
.then(() => {
setTodos(todos.filter((todo) => todo.id !== id));
})
.catch((error) => console.error('Error deleting to-do:', error));
};
return (
<div className="App">
<header className="App-header">
<h1>To-Do List</h1>
<input
type="text"
placeholder="Add a task"
value={task}
onChange={(e) => setTask(e.target.value)}
/>
<button onClick={addTodo}>Add To-Do</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.task} <button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</header>
</div>
);
}
export default TodoList;
Key Points:
- Authentication Flow:
- The user either signs up or signs in.
- After a successful sign-up and confirmation, or sign-in, the JWT token is passed to the
App.js
component. - If the user is authenticated (i.e., they have a valid JWT token), the
TodoList
component is displayed. - UI Control:
- The main
App.js
component controls the display of either the Sign-In/Sign-Up forms or the To-Do List, depending on whether the user is authenticated or not. - Bearer Token Authentication:
- The JWT token obtained after sign-in is used for all API requests to the API Gateway by including it in the
Authorization
header using the Bearer scheme.
Final Notes on React App Restructure:
By following this structure, you now have a well-organized React app that handles:
- User authentication via AWS Cognito.
- Properly hides and shows the Sign-In, Sign-Up, and To-Do List components based on the authentication state.
- Uses Bearer token authentication to securely access the protected API Gateway resources.
Conclusion
In this tutorial, we’ve successfully built a serverless full-stack application using AWS CDK, integrating key services like Cognito for authentication, API Gateway for secure API access, Lambda for backend logic, and DynamoDB for data storage. On the frontend, we used React to create a user-friendly interface with Bearer token authentication, ensuring only authenticated users can access protected resources.
This project showcases the power of serverless architecture, allowing you to focus on development while AWS handles the infrastructure. You’ve learned how to build, secure, and scale your application effortlessly using AWS’s managed services.
If you’re looking to further enhance your knowledge of infrastructure as code and serverless architecture, check out our IaC Transformation Wheel. It’s a detailed guide that will help you implement and optimize your infrastructure, taking your projects to the next level.