Skip to content

Full-Stack AWS Serverless Uncovered: Build, Secure, and Scale with Confidence

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Serverless Fullstack Architecture

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.

  1. 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
  1. 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.

  1. 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
  1. 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.

  1. 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’s build/ 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.

  1. Build the React app again (if you haven’t):
   cd my-app
   npm run build
  1. 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.
  1. 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:

  1. Create a Lambda function using AWS CDK.
  2. Set up an API Gateway that triggers the Lambda function.
  3. 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

  1. 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
  1. 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.
  1. Create the Lambda Function Code: In the root of your CDK project (at the same level as lib), create a folder named lambda and inside it, create a file named handler.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.

  1. Update App.js in React App: In the my-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 and region with the actual values from the API Gateway URL that will be output after the deployment.

Deploy the Stack

  1. 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.

  1. 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.
  1. 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

  1. Access the React App: Open the S3 URL (output by CDK) in your browser. This is the public website hosting the React app.
  2. 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:

  1. Set up an S3 bucket to host a React app using AWS CDK.
  2. Created a Lambda function that returns static content.
  3. Created an API Gateway that triggers the Lambda function when a GET request is made.
  4. Updated the React app to call the API and display the response.
  5. 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:

  1. Set up a DynamoDB table to store to-do items.
  2. Update the Lambda function to handle add, update, and delete operations on to-do items.
  3. 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.

  1. Modify the CDK Stack to Create a DynamoDB Table:
    In your lib/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.

  1. 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, and completed 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.

  1. 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:

  1. Set up AWS Cognito for password-based authentication and self-sign-up using CDK.
  2. Create an API Gateway with Cognito Authorization using CDK.
  3. Protect API Gateway routes that connect to Lambda functions.
  4. Implement Bearer token authentication in the React app for secure API calls.
  5. 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:

  1. Sign-In and Sign-Up Forms: Separate components for signing up and signing in, controlled by the main App component.
  2. Authentication State Management: Use of state to track whether the user is authenticated and has a valid token.
  3. Showing/Hiding Components: Proper UI rendering based on authentication state.
  4. 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:

  1. User authentication via AWS Cognito.
  2. Properly hides and shows the Sign-In, Sign-Up, and To-Do List components based on the authentication state.
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *