Back to Portfolio

How to Build a Serverless Website with reCAPTCHA on AWS

Serverless Resume Architecture

This project documents my experience building a serverless personal website on AWS. I didn't want to run or maintain servers, pay for idle Compute or EC2 time, or deal with patching and scaling. So I built a serverless site architecture using S3 for static hosting, CloudFront for global content delivery and TLS, and a contact form backend powered by API Gateway, Lambda, and SNS with Google reCAPTCHA for bot prevention.

Hosting is at: sdmcqueen.com — served entirely through AWS CloudFront with a custom domain and TLS certificate.

Application Architecture

The application architecture uses the following AWS services:

  1. Amazon S3 — stores HTML, CSS, JS, and image assets in a private bucket
  2. Amazon CloudFront — serves content globally via edge locations with HTTPS
  3. Amazon Route 53 — domain registration and DNS management
  4. AWS Certificate Manager (ACM) — free TLS certificate with DNS validation
  5. Amazon API Gateway — REST API endpoint for the contact form
  6. AWS Lambda — Node.js function that processes form submissions
  7. Amazon SNS — sends email notifications for each form submission
S3 CloudFront Route 53 ACM API Gateway Lambda SNS Node.js HTML / CSS / JS reCAPTCHA v2

Create S3 Bucket for Static Hosting

  1. Open the Amazon S3 console
  2. Click Create bucket and name it (e.g., your domain name)
  3. Leave Block all public access enabled — CloudFront OAC will handle access
  4. Create the bucket, then upload your website files (index.html, CSS, JS, images)
Important: Do not enable static website hosting on the bucket. With Origin Access Control (OAC), CloudFront accesses S3 via the REST API endpoint, not the website endpoint.

Create SNS Topic and Subscription

  1. Open the SNS Console and select Topics
  2. Click Create topic → type Standard
  3. Create a Subscription — select Email protocol and enter your email address
  4. Check your inbox and confirm the subscription

Build Serverless Backend on AWS Lambda

  1. Open AWS Lambda and select Create function
  2. Select Author from scratch, choose Node.js runtime
  3. Enter a function name (e.g., contactFormHandler)
  4. Attach an IAM role with SNS publish permissions

The Lambda function verifies the reCAPTCHA token with Google's API, validates the input fields, then publishes a formatted message to the SNS topic:

lambda/index.mjs
import https from 'https';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';

const sns     = new SNSClient({});
const TOPIC   = process.env.SNS_TOPIC_ARN;
const SECRET  = process.env.RECAPTCHA_SECRET;
const ALLOWED = process.env.ALLOWED_ORIGIN;

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

  // Verify reCAPTCHA token with Google
  const captchaOk = await verifyCaptcha(captcha);
  if (!captchaOk) return respond(403, 'Captcha failed');

  // Publish to SNS
  await sns.send(new PublishCommand({
    TopicArn: TOPIC,
    Subject:  `Contact: ${subject}`,
    Message:  `From: ${name} (${email})\n\n${message}`,
  }));

  return respond(200, 'Message sent');
};

Deploy REST API with API Gateway

  1. Open the API Gateway console and select REST API
  2. Create a new resource (e.g., /contact)
  3. Create a POST method → integration type Lambda Function
  4. Enable CORS on the resource — set Access-Control-Allow-Origin to your domain
  5. Deploy the API to a stage (e.g., prod)
  6. Copy the Invoke URL for use in your frontend JavaScript
CORS Syntax: When setting gateway response header mappings, the origin URL must be wrapped in single quotes: 'https://yoururl.com'

Set Up a Serverless Website with CloudFront

Create the CloudFront Distribution

  1. Open CloudFront and click Create distribution
  2. Set Origin domain to your S3 bucket (select from dropdown)
  3. Under Origin access, select Origin access control settings (OAC)
  4. Click Create new OAC → keep defaults → Create
  5. Set Viewer protocol policy to Redirect HTTP to HTTPS
  6. Under Response headers policy, select SecurityHeadersPolicy (AWS managed)
  7. Set Default root object to index.html
  8. Click Create distribution
  9. Copy the S3 bucket policy from the blue banner and paste it into your S3 bucket permissions
Note: AWS has deprecated Origin Access Identity (OAI) in favor of Origin Access Control (OAC). OAC provides more granular S3 bucket policy controls and supports additional signing behaviors.

S3 Bucket Policy for OAC

S3 Bucket Policy
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipal",
    "Effect": "Allow",
    "Principal": {
      "Service": "cloudfront.amazonaws.com"
    },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::your-bucket-name/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DIST_ID"
      }
    }
  }]
}

Configure Custom Domain with Route 53

  1. Register a domain through Route 53 (or transfer an existing one)
  2. In Route 53 Hosted Zones, select your domain
  3. Create an A record — toggle Alias on → route to CloudFront distribution
  4. Create a second A record for www pointing to the same CloudFront distribution

Request SSL Certificate with AWS Certificate Manager

  1. Switch to the us-east-1 (N. Virginia) region — required for CloudFront
  2. Open ACM and click Request a certificate
  3. Select Public certificate → enter your domain name (e.g., sdmcqueen.com)
  4. Add an additional name: *.sdmcqueen.com for wildcard coverage
  5. Choose DNS validation → click Request
  6. Click Create records in Route 53 to automatically add the CNAME validation records
  7. Wait for status to change to Issued (usually 5-15 minutes)
Region requirement: CloudFront only accepts ACM certificates from us-east-1, regardless of where your S3 bucket is located.

Attach Certificate to CloudFront

  1. Go to your CloudFront distributionGeneralEdit
  2. Add Alternate domain names (CNAMEs): your apex domain and www subdomain
  3. Under Custom SSL certificate, select the ACM certificate
  4. Set Security policy to TLSv1.2_2021
  5. Save changes and wait for deployment to complete

Frontend: Contact Form with reCAPTCHA

The contact form uses Google reCAPTCHA v2 to prevent automated submissions. The reCAPTCHA widget is rendered in the form, and the token is sent along with the POST request to API Gateway:

assets/js/main.js (contact form handler)
const API_URL = 'https://<api-id>.execute-api.us-east-1.amazonaws.com/prod/contact';

contactForm.addEventListener('submit', async (e) => {
  e.preventDefault();

  const captcha = grecaptcha.getResponse();
  if (!captcha) { /* show error */ return; }

  const payload = {
    name:    document.getElementById('name').value,
    email:   document.getElementById('email').value,
    subject: document.getElementById('subject').value,
    message: document.getElementById('message').value,
    captcha: captcha,
  };

  const res = await fetch(API_URL, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify(payload),
  });
});

Security Controls

  • S3 bucket is private — public access blocked at the bucket level; only CloudFront OAC can retrieve objects
  • TLS 1.2+ enforced — ACM certificate on CloudFront with minimum TLSv1.2_2021 security policy
  • Security headers — AWS managed SecurityHeadersPolicy applies HSTS, X-Content-Type-Options, and X-Frame-Options
  • CORS origin-restricted — API Gateway only accepts requests from the production domain
  • reCAPTCHA v2 — Lambda verifies the captcha token server-side before processing
  • IAM least-privilege — Lambda role only has permissions for SNS:Publish on the specific topic ARN
  • Input validation — Lambda sanitizes and validates all form fields before publishing

Lessons Learned

  1. OAI is deprecated — The CloudFront console no longer shows Origin Access Identity. Use Origin Access Control (OAC) instead, which provides more granular S3 bucket policy controls.
  2. ACM must be in us-east-1 — CloudFront only accepts certificates from N. Virginia, regardless of your S3 bucket region.
  3. DNS propagation affects ACM — Newly registered domains may take time for nameserver records to propagate. ACM DNS validation won't succeed until the CNAME records are resolvable.
  4. CORS requires single quotes — API Gateway mapping expressions need the origin URL wrapped in single quotes or you'll get an "Invalid mapping expression" error.
  5. SecurityHeadersPolicy is free — Custom response header policies require a CloudFront paid plan, but the AWS managed SecurityHeadersPolicy provides common security headers at no cost.

References

  1. AWS Documentation — CloudFront Origin Access Control
  2. AWS Documentation — Certificate Manager DNS Validation
  3. AWS Documentation — API Gateway CORS Configuration
  4. Google reCAPTCHA Developer Guide