Building a serverless Rest API on AWS

Modern APIs need to be secure, versioned, and independently scalable without ops overhead. This post walks through how we built a production REST API using AWS API Gateway, AWS Lambda, and Go — deployed entirely with AWS CDK. We’ll cover the full architecture: request routing, custom API key authorization, multi-Lambda design, and CloudWatch alerting wired to Slack.


Architecture Overview

At a high level, the system sits behind a custom domain and routes all traffic through API Gateway. Every request passes through a Lambda-based authorizer before reaching a backend Lambda function. Backend functions are purpose-built containers deployed from ECR.

                          ┌────────────────────────────────────────────────┐
                          │                  AWS Cloud                     │
                          │                                                │
  Client                  │  ┌──────────────┐    ┌─────────────────────┐  │
  ─────────               │  │              │    │  Lambda Authorizer  │  │
  X-Api-Key ──────────────┼─►│ API Gateway  │───►│  (Node.js / CDK)   │  │
  X-Api-Version           │  │              │    │                     │  │
                          │  └──────┬───────┘    └──────────┬──────────┘  │
                          │         │                        │             │
                          │         │  Authorized            │             │
                          │         ▼                        ▼             │
                          │  ┌──────────────┐    ┌─────────────────────┐  │
                          │  │  Lambda:     │    │   Secrets Manager   │  │
                          │  │  Resources   │    │  (API Key → User    │  │
                          │  └──────┬───────┘    │   Details Map)      │  │
                          │         │             └─────────────────────┘  │
                          │  ┌──────────────┐                              │
                          │  │  Lambda:     │                              │
                          │  │  Analytics   │                              │
                          │  └──────┬───────┘                              │
                          │         │                                       │
                          │  ┌──────▼───────┐    ┌─────────────────────┐  │
                          │  │  PostgreSQL  │    │  External Data API  │  │
                          │  │  (VPC)       │    │  (HTTP client)      │  │
                          │  └──────────────┘    └─────────────────────┘  │
                          └────────────────────────────────────────────────┘

Two backend Lambda functions handle distinct domains:

LambdaRoutesData source
Resources LambdaGET /resources, GET /resources/{id}PostgreSQL (in-VPC)
Analytics LambdaGET /analytics/{reportId}External HTTP API

This separation means each Lambda carries only the dependencies and permissions it needs. Adding a new domain is a new Lambda + new routes — no shared blast radius.


Infrastructure as Code: AWS CDK

The entire stack is defined in TypeScript CDK. Resources are modelled as StackConfig entries in a feature registry, which lets you deploy subsets independently:

export enum FeatureSet {
  All        = "all",
  ApiGateway = "api-gateway",  // IAM role + Lambdas + Authorizer + API GW
  Repository = "repository",   // ECR only
  Alarms     = "alarms",       // SNS + CloudWatch + Chatbot
}

The dependency graph for a full API Gateway deployment looks like this:

EcrRepository
    └── IamLambdaRole
            ├── ResourcesLambda ──────────┐
            ├── AnalyticsLambda ──────────┤
            └── LambdaAuthorizer ─────────┤
                                          ▼
                       DnsCustomDomain ──► ApiGatewayStack

CDK cross-stack references use CfnOutput / Fn.importValue, so each stack is independently deployable and ARNs are never hardcoded.


API Key Authorization

The Flow

Every request must carry an X-Api-Key header. The API Gateway delegates authorization to a Lambda authorizer before the request ever touches a backend function.

Request arrives
     │
     ▼
API Gateway checks X-Api-Key header present
     │ missing → 400 Bad Request
     ▼
Lambda Authorizer invoked
     │
     ├─► Fetch API key → user details map from Secrets Manager
     │        (map: "api-key-string" → { companyId, userId, authorizedRequests })
     │
     ├─► Validate API key exists in map
     │        missing → Unauthorized
     │
     ├─► Validate request against authorizedRequests
     │        [ { method: "GET", path: "/resources/{id}", queryParameters: [...] } ]
     │        no match → Deny IAM policy returned
     │
     └─► Allow IAM policy returned → request forwarded to Lambda

Fine-Grained Authorization

The key insight is that authorization is per-request, not just per-key. Each API key maps to a list of authorizedRequests:

{
  "sk_live_abc123": {
    "companyId": 42,
    "userId": 7,
    "authorizedRequests": [
      { "method": "GET", "path": "/resources", "queryParameters": [] },
      { "method": "GET", "path": "/resources/{id}", "queryParameters": [] }
    ]
  }
}

Path parameters like {id} are matched with a regex: {[^/]+}[^/]+. This means a key scoped only to /resources cannot accidentally reach /analytics, even if both are behind the same API Gateway.

The policy returned to API Gateway carries context that downstream Lambdas can read:

{
  principalId: `${companyId}-${userId}`,
  policyDocument: { /* Allow/Deny execute-api:Invoke */ },
  context: { companyId, userId },
  usageIdentifierKey: apiKey,  // feeds API GW usage plan tracking
}

Prototype Pollution Guard

Because the API key comes from an untrusted request header and is used as an object key lookup, the authorizer explicitly guards against prototype pollution:

if (Object.prototype.hasOwnProperty.call(Object.prototype, apiKey)) {
  callback(RESPONSE_UNAUTHORIZED)
  return
}

Version-Based Routing in Go

Inside each Lambda, an ApiRouter dispatches requests by method, path regex, and API version. Callers must send an X-Api-Version header (e.g. 2024-12-01).

Incoming request
     │
     ▼
Check X-Api-Version present
     │ missing → 400 Bad Request
     ▼
Match routes by (Method + PathPattern regex)
     │ no match → 404 Not Found
     ▼
Match route by Version
     │ unsupported → 400 "supported versions: [2025-03-01 (latest), 2024-12-01]"
     ▼
Call route.Handler(ctx, request)

Routes are registered as slices of Route structs:

type Route struct {
    Method      string
    PathPattern *regexp.Regexp
    Version     string
    Handler     func(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)
}

A typical route table:

routes := []Route{
    {
        Method:      "GET",
        PathPattern: regexp.MustCompile(`^/resources$`),
        Version:     "2024-12-01",
        Handler:     handlers.NewGetResources(srv).Handle,
    },
    {
        Method:      "GET",
        PathPattern: regexp.MustCompile(`^/resources/[a-fA-F0-9\-]+$`),
        Version:     "2024-12-01",
        Handler:     handlers.NewGetResourceByID(srv).Handle,
    },
}

This means you can add a 2025-06-01 version of the same path alongside the existing one — old clients keep working, new clients opt in by sending the new version header. No URL versioning (/v2/) required.


Lambda Container Images

Both backend Lambdas are packaged as container images and stored in ECR. The CDK stack pulls a specific image by tag:

<repo-name>-<image-name>-<git-sha>

Pinning images to a git SHA means rollbacks are a single update-function-code call — no rebuilding required. The Lambda is deployed inside a VPC to keep database traffic off the public internet.

                ECR Repository
                      │
                      │ image:<git-sha>
                      ▼
             Lambda Function (VPC)
                      │
              ┌───────┴────────┐
              │                │
         PostgreSQL       Secrets Manager
         (read/write       (config, keys)
          replicas)

The Lambda’s IAM role is purpose-scoped — it can read the RDS secret and the map secret, nothing else.


Observability & Alerting

CloudWatch alarms watch for both 4XX and 5XX error rates on the API Gateway stage. When either threshold is breached, an SNS topic fans out to an AWS Chatbot integration that posts directly to a Slack channel.

API Gateway
     │
     ├── 4XX metric ──► CloudWatch Alarm ──► SNS Topic ──► AWS Chatbot ──► Slack
     │
     └── 5XX metric ──► CloudWatch Alarm ──┘

Both alarms use treatMissingData: NOT_BREACHING so periods with zero traffic don’t page the on-call team.

The alarm and notification stacks are independent of the API stacks — you can deploy or tear them down without touching the Gateway or Lambda configuration.


Key Design Decisions

One Lambda per domain, not one Lambda per route. The API Gateway fan-out to separate Lambdas is done at the resource group level (/resources/* vs /analytics/*), not per-endpoint. This keeps cold start surface area predictable and IAM policies tight.

API key authorization in a dedicated Lambda. Keeping auth out of the application Lambda means you can swap, update, or audit the auth logic without touching application code. The authorizer result is cached per-request (TTL = 0 in this configuration, prioritising correctness over latency).

Date-based API versioning. X-Api-Version: 2024-12-01 is explicit and human-readable. It sidesteps the /v1/ vs /v2/ URL debate and lets routes coexist at the same path until old versions are retired.

CDK feature sets for incremental deploys. The FeatureSet enum means a CI job can deploy only the api-gateway set on code changes and only the alarms set when alert thresholds change — without risking an unintentional ECR or IAM diff.


Summary

This architecture gives you:

  • Zero servers to manage (API Gateway + Lambda)
  • Per-key, per-endpoint authorization with Secrets Manager as the source of truth
  • Date-based API versioning without URL fragmentation
  • Independent Lambda deployments per domain
  • Full observability with Slack alerting
  • Reproducible container images pinned to git SHAs
  • Local dev parity via Lambda RIE

The same pattern scales cleanly: add a new domain by creating a new Lambda, a new route table, and a new resource in the API Gateway stack — everything else (auth, observability, DNS) is already in place.

Leave a Comment

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

Scroll to Top