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
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-portalSAM 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: CognitoAuthorizerDynamoDB 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-apiKey Takeaways
- SAM template defines all infrastructure as code — the entire portal deploys with
sam deploy - Cognito handles auth complexity — user pools, tokens, and JWT validation without custom code
- Single-table DynamoDB design with a GSI lets you query by user or project efficiently
- Pre-signed S3 URLs let clients upload directly to S3 — Lambda never handles file bytes
- PAY_PER_REQUEST billing on DynamoDB and Lambda means zero cost when idle — perfect for portals with unpredictable traffic
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.