Back to articles
AWS / ServerlessJuly 14, 20266 min read

Building a Serverless Contact Form with API Gateway, Lambda & SES

Design and deploy a fully serverless, event-driven contact form backend on AWS using API Gateway, Lambda (Node.js), and SES. Covers IAM least-privilege, CORS, XSS sanitization, idempotent deployments, and the real-world debugging pitfalls of IAM resource policy conflicts.

A contact form is one of the first real features most websites need — and it's a perfect use case for a serverless architecture. No server to manage, no idle compute costs, and it scales to zero when no one's submitting a form.

Here's the full architecture and the hard-won debugging lessons from building it with AWS API Gateway, Lambda, and SES.

Architecture Overview

code read-only
Browser (HTML Form)
        │ POST /contact (JSON)
        ▼
API Gateway (REST API)
        │ Lambda Proxy Integration
        ▼
Lambda Function (Node.js 24.x)
        │ AWS SDK v3 → SES
        ▼
SES → Email delivered to inbox

Every component is managed and serverless. You pay only for what you use, and the stack costs effectively $0 at normal portfolio traffic levels.

---

Phase 1: Manual Setup (Understanding the Stack)

Step 1: Verify Email Identities in SES

AWS SES starts in Sandbox mode, meaning you can only send emails to verified addresses. Before writing a line of code:

1. Go to SES → Verified identities → Create identity 2. Enter your sender and recipient email addresses 3. Click the verification link AWS sends to each inbox

Production note: To send to unverified addresses (i.e., real users), you need to request production SES access via the AWS console. For a personal contact form where you're the recipient, Sandbox is fine.

Step 2: IAM Role with Least Privilege

The Lambda function needs exactly two permissions — nothing more:

1. AWSLambdaBasicExecutionRole — write execution logs to CloudWatch 2. A custom inline policy for SES send permissions:

json read-only
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["ses:SendEmail", "ses:SendRawEmail"],
    "Resource": "*"
  }]
}

The trust policy allows only lambda.amazonaws.com to assume this role. This is the IAM Principal of Least Privilege model: the role is scoped to one service and two actions.

Step 3: The Lambda Function (Node.js 24.x with AWS SDK v3)

The handler uses AWS SDK v3's modular imports (smaller bundle size) and includes input sanitization against XSS:

javascript read-only
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const ses = new SESClient({ region: process.env.AWS_REGION });

// Sanitize HTML special characters to prevent XSS in email content const sanitize = (str) => String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;');

export const handler = async (event) => { const body = JSON.parse(event.body); const { name, email, message } = body;

// Regex validation before sending const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return { statusCode: 400, body: JSON.stringify({ error: 'Invalid email' }) }; }

const command = new SendEmailCommand({ Source: process.env.SENDER_EMAIL, Destination: { ToAddresses: [process.env.RECIPIENT_EMAIL] }, Message: { Subject: { Data: Contact from ${sanitize(name)} }, Body: { Text: { Data: Name: ${sanitize(name)}\nEmail: ${sanitize(email)}\n\n${sanitize(message)} } } } });

await ses.send(command); return { statusCode: 200, body: JSON.stringify({ success: true }) }; };

Step 4: API Gateway with CORS

One of the trickiest parts is CORS. Browser preflight requests (OPTIONS) must be handled before any POST reaches Lambda:

1. Create a REST API (not HTTP API — more control) 2. Create /contact resource → POST method with Lambda proxy integration ON 3. Enable CORS on the resource — this auto-creates an OPTIONS method with a MOCK integration and the Access-Control-Allow-* headers 4. Deploy to a stage named prod

Lambda Proxy Integration: This passes the entire HTTP request (headers, query params, body) directly to Lambda as a JSON object. Lambda is responsible for returning the status code and headers. Without this, API Gateway tries to map the response, often causing silent failures.

---

Phase 2: CLI Automation & The Debugging Gauntlet

Once the manual setup was working, I automated it with deploy.sh and cleanup.sh. This is where the real lessons happened.

Pitfall 1: Idempotent Resource Creation

AWS CLI commands like aws apigateway create-rest-api don't fail if a resource already exists — they create a duplicate. Running deploy.sh twice would leave you with two API Gateways and a Lambda pointing at the wrong one.

Fix: Always implement a "check-then-create" pattern:

bash read-only
API_ID=$(aws apigateway get-rest-apis \
  --query "items[?name=='ContactFormAPI'].id" \
  --output text)

if [ -z "$API_ID" ]; then API_ID=$(aws apigateway create-rest-api \ --name "ContactFormAPI" \ --query 'id' --output text) fi

Pitfall 2: The Silent 500 Internal Server Error (IAM Policy Conflict)

This was the hardest bug to track down. After a second deployment run, the API started returning 500 Internal Server Error on every request.

Debugging process: 1. Checked CloudWatch Logs for the Lambda function — no recent logs. This meant API Gateway wasn't even reaching Lambda. 2. Ran aws lambda get-policy --function-name ContactFormHandler to inspect the Lambda resource policy. 3. Root cause: The Lambda's resource policy still referenced the old API Gateway ID. The deploy.sh had created a new API Gateway, but the aws lambda add-permission command had silently failed because a statement with the same --statement-id already existed from the previous run. The || true suppressed the error.

Fix: Explicitly remove the old permission before adding the new one:

bash read-only
# Remove stale permission (ignore error if it doesn't exist)
aws lambda remove-permission \
  --function-name ContactFormHandler \
  --statement-id "apigateway-invoke" 2>/dev/null || true

Add fresh permission scoped to the NEW API Gateway

aws lambda add-permission \ --function-name ContactFormHandler \ --statement-id "apigateway-invoke" \ --action "lambda:InvokeFunction" \ --principal "apigateway.amazonaws.com" \ --source-arn "arn:aws:execute-api:$REGION:$ACCOUNT_ID:$API_ID/prod/POST/contact"

Pitfall 3: Configuration Drift (DRY Principle)

After building cleanup.sh, I realized it hardcoded the same resource names as deploy.sh. If I renamed the API Gateway in deploy.sh, cleanup.sh would silently fail to find it, leaving orphaned billable resources.

Fix: Extract all shared variables into a centralized config.sh:

bash read-only
# config.sh — single source of truth
export REGION="ap-south-1"
export LAMBDA_FUNCTION_NAME="ContactFormHandler"
export API_NAME="ContactFormAPI"
export IAM_ROLE_NAME="ContactFormLambdaRole"

Both deploy.sh and cleanup.sh source this file at runtime:

bash read-only
source ./config.sh

This guarantees the deployment and teardown scripts are always synchronized.

---

Key Takeaways

  • Lambda Proxy Integration is almost always what you want. It gives Lambda full control over the response and eliminates mapping template complexity.
  • Silent || true in automation scripts can mask critical bugs. The IAM policy conflict was hidden because the error was suppressed. Consider logging or alerting on suppressed errors.
  • Check-then-create is the pattern for idempotent AWS CLI scripts. AWS doesn't enforce uniqueness for many resources — your scripts must.
  • Centralize configuration with a config.sh. Configuration drift between deploy and cleanup scripts leaves orphaned, billable resources. One source of truth prevents this.
  • SES Sandbox is fine for personal projects. For a contact form where you're the only recipient, there's no need to request production SES access.
  • P
    Pratik Sangani
    Backend Developer & Architect
    Get in touch