Back to blog
cloudbeginner

Project: Serverless Client Portal on AWS

Build a complete serverless client portal using AWS Lambda, API Gateway, DynamoDB, Cognito, and S3. Covers authentication, CRUD operations, file storage, and infrastructure as code with SAM.

Asma HafeezApril 17, 20265 min read
awsserverlesslambdadynamodbcognitoapi-gatewayproject
Share:𝕏

Project: Serverless Client Portal

Build a client portal where authenticated users manage their projects, upload files, and communicate — all on AWS serverless infrastructure. No servers to manage, scales to zero, and you only pay for actual usage.


Architecture Overview

[Browser / Frontend]
        |
        | HTTPS
        ▼
[API Gateway]  ←→  [Cognito User Pool]
        |                  (auth)
        ▼
[Lambda Functions]
   ├── auth-authorizer
   ├── projects-handler
   ├── files-handler
   └── messages-handler
        |
   ┌────┴──────────┐
   ▼               ▼
[DynamoDB]       [S3 Bucket]
(data)           (files)

Project Setup with SAM

Bash
# Install AWS SAM CLI
pip install aws-sam-cli

# Create project
sam init --runtime dotnet8 --name client-portal
cd client-portal

SAM Template

YAML
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime:     dotnet8
    MemorySize:  512
    Timeout:     30
    Environment:
      Variables:
        TABLE_NAME:  !Ref ClientPortalTable
        BUCKET_NAME: !Ref FilesBucket

Resources:

  # Cognito User Pool
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: ClientPortalUsers
      AutoVerifiedAttributes: [email]
      PasswordPolicy:
        MinimumLength: 8
        RequireNumbers: true
        RequireSymbols: true

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_PASSWORD_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH

  # DynamoDB table  single-table design
  ClientPortalTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ClientPortal
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - { AttributeName: PK, AttributeType: S }
        - { AttributeName: SK, AttributeType: S }
        - { AttributeName: GSI1PK, AttributeType: S }
      KeySchema:
        - { AttributeName: PK, KeyType: HASH }
        - { AttributeName: SK, KeyType: RANGE }
      GlobalSecondaryIndexes:
        - IndexName: GSI1
          KeySchema:
            - { AttributeName: GSI1PK, KeyType: HASH }
            - { AttributeName: SK,     KeyType: RANGE }
          Projection: { ProjectionType: ALL }
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true

  # S3 Bucket
  FilesBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
          - AllowedOrigins: ['*']
            AllowedMethods: [PUT, GET]
            AllowedHeaders: ['*']

  # Lambda Functions
  ProjectsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: ClientPortal.Lambda::ClientPortal.Lambda.ProjectsHandler::FunctionHandler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ClientPortalTable
      Events:
        GetProjects:
          Type: Api
          Properties:
            Path:   /projects
            Method: get
            Auth:
              Authorizer: CognitoAuthorizer
        CreateProject:
          Type: Api
          Properties:
            Path:   /projects
            Method: post
            Auth:
              Authorizer: CognitoAuthorizer

  FilesFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: ClientPortal.Lambda::ClientPortal.Lambda.FilesHandler::FunctionHandler
      Policies:
        - S3CrudPolicy:
            BucketName: !Ref FilesBucket
        - DynamoDBCrudPolicy:
            TableName: !Ref ClientPortalTable
      Events:
        GetUploadUrl:
          Type: Api
          Properties:
            Path:   /files/upload-url
            Method: get
            Auth:
              Authorizer: CognitoAuthorizer

DynamoDB Single-Table Design

Entity         PK                    SK
──────────────────────────────────────────
User           USER#         PROFILE
Project        PROJECT#   METADATA
               USER#         PROJECT#   (ownership index)
File           PROJECT#   FILE#
Message        PROJECT#   MSG##
C#
// Models
public record ProjectItem
{
    public string PK        { get; init; } = "";  // PROJECT#<id>
    public string SK        { get; init; } = "";  // METADATA
    public string GSI1PK   { get; init; } = "";  // USER#<ownerId>
    public string Id        { get; init; } = "";
    public string Name      { get; init; } = "";
    public string Status    { get; init; } = "active";
    public string OwnerId   { get; init; } = "";
    public string CreatedAt { get; init; } = "";
}

Lambda Function — Projects Handler

C#
// ProjectsHandler.cs
public class ProjectsHandler
{
    private readonly IAmazonDynamoDB _dynamo = new AmazonDynamoDBClient();
    private readonly string _table = Environment.GetEnvironmentVariable("TABLE_NAME")!;

    public async Task<APIGatewayProxyResponse> FunctionHandler(
        APIGatewayProxyRequest request,
        ILambdaContext context)
    {
        var userId = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
        if (userId is null) return Unauthorized();

        return request.HttpMethod switch
        {
            "GET"  => await GetProjectsAsync(userId),
            "POST" => await CreateProjectAsync(userId, request.Body),
            _      => MethodNotAllowed()
        };
    }

    private async Task<APIGatewayProxyResponse> GetProjectsAsync(string userId)
    {
        var client = new DynamoDBContext(_dynamo);

        // Query by owner using GSI1
        var config = new DynamoDBOperationConfig
        {
            IndexName = "GSI1",
            QueryFilter = new List<ScanCondition>
            {
                new("GSI1PK", ScanOperator.Equal, $"USER#{userId}")
            }
        };

        var projects = await client
            .QueryAsync<ProjectItem>($"USER#{userId}", config)
            .GetRemainingAsync();

        return Ok(projects);
    }

    private async Task<APIGatewayProxyResponse> CreateProjectAsync(string userId, string body)
    {
        var req = JsonSerializer.Deserialize<CreateProjectRequest>(body)!;
        var id  = Guid.NewGuid().ToString();

        var client  = new DynamoDBContext(_dynamo);
        var project = new ProjectItem
        {
            PK        = $"PROJECT#{id}",
            SK        = "METADATA",
            GSI1PK   = $"USER#{userId}",
            Id        = id,
            Name      = req.Name,
            OwnerId   = userId,
            CreatedAt = DateTimeOffset.UtcNow.ToString("O")
        };

        await client.SaveAsync(project);
        return Created(project);
    }

    private static APIGatewayProxyResponse Ok(object body) =>
        new() { StatusCode = 200, Body = JsonSerializer.Serialize(body),
                Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };

    private static APIGatewayProxyResponse Created(object body) =>
        new() { StatusCode = 201, Body = JsonSerializer.Serialize(body),
                Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };

    private static APIGatewayProxyResponse Unauthorized() =>
        new() { StatusCode = 401, Body = "{\"error\":\"Unauthorized\"}" };

    private static APIGatewayProxyResponse MethodNotAllowed() =>
        new() { StatusCode = 405, Body = "{\"error\":\"Method not allowed\"}" };
}

Files Handler — Pre-signed S3 URLs

C#
public class FilesHandler
{
    private readonly IAmazonS3 _s3 = new AmazonS3Client();
    private readonly string _bucket = Environment.GetEnvironmentVariable("BUCKET_NAME")!;

    public async Task<APIGatewayProxyResponse> FunctionHandler(
        APIGatewayProxyRequest request, ILambdaContext context)
    {
        var userId    = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
        var projectId = request.QueryStringParameters?["projectId"];
        var fileName  = request.QueryStringParameters?["fileName"];

        if (userId is null || projectId is null || fileName is null)
            return new() { StatusCode = 400, Body = "{\"error\":\"Missing parameters\"}" };

        var key        = $"{userId}/{projectId}/{fileName}";
        var expiry     = DateTime.UtcNow.AddMinutes(15);
        var presigned  = new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key        = key,
            Verb       = HttpVerb.PUT,
            Expires    = expiry,
            ContentType = "application/octet-stream"
        };

        var url = await _s3.GetPreSignedURLAsync(presigned);

        return new()
        {
            StatusCode = 200,
            Body       = JsonSerializer.Serialize(new { uploadUrl = url, key }),
            Headers    = new Dictionary<string, string> { ["Content-Type"] = "application/json" }
        };
    }
}

Deploy and Test

Bash
# Build and deploy
sam build
sam deploy --guided

# Test with curl
TOKEN=$(aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id <UserPoolClientId> \
  --auth-parameters USERNAME=test@example.com,PASSWORD=Test1234! \
  --query 'AuthenticationResult.IdToken' --output text)

curl -H "Authorization: Bearer $TOKEN" \
  https://<api-id>.execute-api.eu-west-1.amazonaws.com/Prod/projects

# Local testing
sam local start-api

Key Takeaways

  1. SAM template defines all infrastructure as code — the entire portal deploys with sam deploy
  2. Cognito handles auth complexity — user pools, tokens, and JWT validation without custom code
  3. Single-table DynamoDB design with a GSI lets you query by user or project efficiently
  4. Pre-signed S3 URLs let clients upload directly to S3 — Lambda never handles file bytes
  5. PAY_PER_REQUEST billing on DynamoDB and Lambda means zero cost when idle — perfect for portals with unpredictable traffic

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.