serverlessaws-s3eventbridge

Building a serverless contact manager with Lambda, API Gateway and Cognito

Aug 1, 2026 3 min read

Contact Book is a small contact management app, but it's built the way I'd build something much bigger: no servers to patch, no idle compute to pay for, and every piece scoped down to exactly the permissions it needs. Auth runs through Cognito, uploads go straight from the browser to S3, and cleanup happens on its own schedule through EventBridge and SQS. Here's how those three pieces fit together.

Authentication with Cognito

Every API route sits behind a Cognito User Pool authorizer on API Gateway. The frontend signs a user in against Cognito directly, gets back a JWT, and attaches it to every request. API Gateway validates the token's signature and expiry before the request ever reaches a Lambda function — so handler code never has to think about whether a caller is authenticated, only about what sub claim it's acting on:

import type { APIGatewayProxyHandlerV2WithJWTAuthorizer } from "aws-lambda";
 
export const handler: APIGatewayProxyHandlerV2WithJWTAuthorizer = async (event) => {
  const userId = event.requestContext.authorizer.jwt.claims.sub as string;
 
  const contacts = await getContactsForUser(userId);
 
  return {
    statusCode: 200,
    body: JSON.stringify(contacts),
  };
};

That sub claim is the only identity signal the rest of the system needs — every table and S3 prefix keys off it, so one user's data is never reachable through another user's token.

Direct-to-S3 uploads with pre-signed URLs

Contact photos don't pass through a Lambda function at all. Routing a multipart upload through API Gateway means paying for the Lambda's execution time while a file streams through it, and running into API Gateway's payload size limits on anything larger than a thumbnail. Instead, the client asks a small Lambda for a pre-signed URL, then uploads directly to S3:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { APIGatewayProxyHandlerV2WithJWTAuthorizer } from "aws-lambda";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const handler: APIGatewayProxyHandlerV2WithJWTAuthorizer = async (event) => {
  const userId = event.requestContext.authorizer.jwt.claims.sub as string;
  const { fileName, contentType } = JSON.parse(event.body ?? "{}");
 
  const command = new PutObjectCommand({
    Bucket: process.env.UPLOADS_BUCKET,
    Key: `contacts/${userId}/${fileName}`,
    ContentType: contentType,
  });
 
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
 
  return {
    statusCode: 200,
    body: JSON.stringify({ uploadUrl }),
  };
};

The IAM role behind that Lambda is scoped as tightly as the request it serves — it can only sign URLs for objects under a user-prefixed path, nothing else in the bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::contact-book-uploads/contacts/*"
    }
  ]
}

The browser then uploads the file directly to S3 with that URL, and the Lambda is already done and billed for milliseconds, not for however long the upload takes.

Async cleanup with EventBridge and SQS

Pre-signed uploads solve the write path, but they create a cleanup problem: a user can request an upload URL and never use it, or replace a contact's photo and leave the old object behind. Rather than checking for orphaned files on every request, S3 emits an event on every object write, and an EventBridge rule filters those down to just the uploads prefix:

{
  "source": ["aws.s3"],
  "detail-type": ["Object Created"],
  "detail": {
    "bucket": { "name": ["contact-book-uploads"] },
    "object": { "key": [{ "prefix": "contacts/" }] }
  }
}

Matching events land on an SQS queue instead of invoking a Lambda directly. That buffer matters: if ten photos are uploaded in the same second, the cleanup Lambda processes them as a batch instead of ten cold starts fighting over the same DynamoDB item, and if the cleanup logic throws, the message just waits for a retry instead of dropping the event on the floor. The consumer itself is intentionally boring — look up the contact record, compare it to the object key that just landed, and delete whichever previous photo it replaced.

That's the whole loop: Cognito decides who someone is, S3 and pre-signed URLs move the bytes without a server in the middle, and EventBridge plus SQS handle the bookkeeping asynchronously, on their own time, without blocking the request that triggered it.