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
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:
{
"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:
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
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:
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:
# 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:
# 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:
source ./config.sh
This guarantees the deployment and teardown scripts are always synchronized.
---
Key Takeaways
|| 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.config.sh. Configuration drift between deploy and cleanup scripts leaves orphaned, billable resources. One source of truth prevents this.