Esmeralda C. Cabrera Ventura ← Articles Dossiers Project details Portfolio LinkedIn
Engineering Dossier Serverless · AWS Live in production

Serverless Contact Form

A contact pipeline for a static site that holds no credentials, grants one permission, and refuses to report a delivery it cannot prove.

Architecture & Implementation
Esmeralda C. Cabrera Ventura
Cloud Architect · Serverless Backend Engineering · IAM Design · Incident Analysis
Scope of this document
A technical account of the backend I designed and built for my portfolio's contact form: the service choices and their costs, the single-action permission model, the response contract that separates "the function ran" from "the email was sent," the CORS incident on the first live submission, and the open issues in priority order.
Engineering Facts
Single deployed stack · US West (N. California)

Long-lived credentials stored0

AWS components in the request pathSES, Lambda, IAM role, Function URL4

IAM actions beyond loggingses:SendEmail, inline policy1

Public routesone method: POST1

Configuration values lifted out of sourceorigin, sender, recipient3

Verified SES identitiesproduction mailbox and a test mailbox2

Build phases documented33 captioned screenshots7

Incidents on first live submission1
Resolved1 · one setting
Messages lost0

Idle costZero

An HTTP 200 from the function is never treated as proof of delivery. The handler reports its own outcome in an explicit success field, and the client reads that field before telling a visitor anything. That one contract is why the incident in section 06 cost nothing.
01
Thesis

A static site cannot hold a secret

So the backend has to be somewhere else, and it has to be honest about what happened.

The portfolio is a static site: HTML, CSS and JavaScript in an S3 bucket, served through CloudFront. That architecture is fast, cheap and very hard to break. It also has no process running anywhere that can receive a POST, no runtime, and no way to hold a credential. A contact form on a site like that needs somewhere else to send its data.

Three approaches were considered. A mailto: link needs no infrastructure but depends on a configured desktop mail client; on a phone or in webmail it often does nothing, the visitor sees a broken link, and the site owner never learns anyone tried. A third-party form service is quick and usually well built, but it puts an outside company in the path of a core site function and routes visitor messages through it. A self-hosted serverless backend is more work up front, keeps the message path inside infrastructure I control, costs effectively nothing at portfolio volume, and teaches SES, Lambda, IAM and CORS in a place where mistakes are cheap.

I chose the third. All three are close to free at this volume, so cost did not decide it. Control and learning value did.

A contact form that quietly loses messages is worse than no contact form, because it also spends the visitor's goodwill.

Requirements, fixed before any resource existed

  • Deliver each message to a single inbox
  • Accept submissions only from the portfolio's own origin
  • Never silently lose a message
  • Hold no credentials in client-side code, which is public by definition
  • Be cheap enough to leave running indefinitely without thinking about it
02
System

Shape of the architecture

Four AWS components, one round trip from the browser, and no API Gateway.

Amazon SES provides a verified sending identity. A Lambda function receives the submission, validates it and calls SES. An IAM role grants that function exactly one permission. A Lambda Function URL exposes the function to the browser over HTTPS. Configuration lives entirely in environment variables, so the deployed code contains no addresses and no secrets.

ComponentTechnologyResponsibility I owned
Static front endS3 · CloudFront · contact.jsSerialises the form fields to JSON, posts them to the endpoint, reads the response's success flag, and renders either a confirmation or a recovery panel
EndpointLambda Function URL · auth NONEA built-in HTTPS endpoint attached directly to the function, accepting POST; the single public surface of the system
HandlerLambda · Node.jsportfolio-contact-form: validates the payload, owns the CORS header, calls SES, and reports its own outcome in the response body
SendingAmazon SES · SendEmailTwo verified email-address identities; delivery to a single verified recipient from the sandbox
AuthorizationIAM execution roleLambda-created logging permissions plus one hand-written inline policy, SendContactFormEmail, granting ses:SendEmail
ConfigurationLambda environment variablesALLOWED_ORIGIN, FROM_ADDRESS, TO_ADDRESS; encrypted at rest with the default Lambda service key
Delivery pipelineS3 upload · CloudFront invalidationEdit the client script, upload it to the site bucket, invalidate /* so visitors stop receiving the cached version

The request path

01

Compose

The visitor completes the form on the portfolio's contact page.

02

Post

contact.js serialises the fields and POSTs JSON to the Function URL over HTTPS.

03

Invoke

The Function URL invokes the handler. After the incident in section 06, CORS on the URL is disabled and the handler alone answers for the origin header.

04

Validate and send

The handler validates the payload and calls the SES SendEmail API from the verified sending identity.

05

Deliver

SES delivers the message to the verified recipient named in configuration. The caller cannot influence the destination.

06

Report

The handler returns a JSON body containing an explicit success flag — its own verdict on whether the send was accepted.

07

Respond to the visitor

The client reads that flag, not the status code, and shows either a confirmation or the recovery panel.

03
Decisions

Each choice, and what it cost

Recorded with the reasoning that produced it. Where a decision is known to be imperfect, that is stated rather than glossed.

DecisionWhyWhat it cost
SES over an SMTP relaySending permission comes through the execution role, with temporary credentials issued by AWS. No password stored or rotated by hand anywhereThe SES sandbox: sending only to verified addresses. Free today; a constraint the moment the system emails the visitor
Address identity over domain identityVerified in under a minute by clicking a link, instead of publishing DNS records and waiting for propagationThe weakest decision in the build. Outgoing mail fails DMARC alignment. The console warned at the time
Function URL over API GatewayOne route, one method, one consumer. Validation, usage plans, WAF and stages are unused surface hereNo built-in rate limiting, no WAF, an AWS-generated hostname, and a public endpoint
Environment variables over constantsRecipient changes without a redeploy; the same function can serve a staging origin; source can be shared without exposing addressesRight for configuration, wrong for real secrets — those would belong in Secrets Manager or Parameter Store
Inline single-action policyA compromised function inherits the ability to send email and nothing elseAbout ninety seconds of extra work. The resource is still a wildcard
Success flag over status codeA handler that catches an SES error and returns normally still produces a 200A few lines in the handler and client. Nothing else

Why the endpoint has no API Gateway

API Gateway is a genuinely capable front door: request validation, throttling, usage plans, custom domains, WAF integration and staged deployments. This endpoint needs none of that. A Function URL is an HTTPS endpoint with CORS configuration and very little else, which is an accurate match for the requirement — and removing a whole service from the design removes a whole service's worth of configuration that could drift.

The costs were accepted with eyes open rather than discovered later. Abuse protection has to come from reserved concurrency or logic inside the handler. And because the auth type is NONE, CORS becomes a browser-side convenience rather than a security boundary, which section 07 addresses directly.

04
Privilege

One action, not a managed policy

Least privilege applied at the smallest useful granularity.

Lambda creates an execution role automatically, and that role starts with the three CloudWatch Logs actions the function needs to write its own logs — and nothing more. Sending email needs an additional grant, and this is where convenience is most tempting. Attaching AmazonSESFullAccess would work immediately. It would also let the function delete identities, alter the suppression list and read sending statistics.

Instead, a hand-written inline policy grants exactly one action. If the function were ever compromised through a payload-handling bug, the attacker inherits the ability to send email and nothing else.

The permission you did not grant is the only one you never have to audit.
Baseline
Logs only
The role as Lambda created it: permission to write its own CloudWatch Logs. It cannot send email.
Created by Lambda
Added
ses:SendEmail
Inline policy SendContactFormEmail. IAM surfaces it across SES, SES v2 and Pinpoint Email, limited to write.
One action
Not granted
Everything else
No identity management, no suppression list, no statistics, no data-plane access to anything.
Denied by default
Next
Scope the resource
Replace the wildcard with the verified identity's ARN and condition on the permitted From address.
Open

Tightening the resource is not cosmetic. Today a compromised function could send as any verified identity in the account. Scoped to one identity ARN with a From-address condition, it could send as one identity, from one address. The difference is small in policy JSON and large in an incident report.

05
Contract

A 200 is not a delivery receipt

The design decision with the most thought behind it and the least visibility in the infrastructure.

A Lambda function that catches an SES exception and returns normally still produces an HTTP 200. A client that treats 200 as success will then tell the visitor their message was sent when it was not. The visitor closes the tab, and the message is gone — with nothing in the browser that looks like an error, because from the browser's point of view nothing failed.

The handler therefore reports its own outcome in a success field inside the response body, and the client checks that field rather than the status code. The two last steps of the request path look redundant. They are the difference between a form that is honest and a form that lies politely.

State 01
Not configured
Until an endpoint is set, the form tells the visitor plainly that it cannot send, rather than failing silently.
Says so
State 02
Delivery failed
The message did not go through. Nothing typed is lost: a pre-filled mail draft and a fallback address are offered.
Recovery panel
State 03
Delivered
The handler's success flag is true, so the form confirms. A status code alone never reaches this state.
Confirmation

This is graceful degradation applied to a contact form. The failure mode was designed with the same care as the success path, on the reasoning that the failure path is the one a visitor meets on the system's worst day — and, as it turned out, on its first.

06
Incident

The duplicated CORS header

The first live submission failed. The fix is less interesting than why the mistake was easy to make.

200
Status returned
2
Layers setting the header
1
Setting changed
0
Messages lost

The browser console reported that the request had been blocked by CORS policy: the Access-Control-Allow-Origin header contained multiple values where only one is permitted. The network entry showed a 200 alongside a failed fetch — exactly the combination the response contract anticipated.

Root cause

CORS headers were being applied in two independent places. When Function URL CORS is enabled, the Lambda service attaches the appropriate headers as the response passes through. The handler was also constructing the header from ALLOWED_ORIGIN and returning it explicitly. The response reached the browser with the header twice, the specification requires a single origin value, and the browser rejected it — after the function had already run to completion.

LayerWhat it didCorrect in isolation?
Function URL CORSOne permitted origin, content-type allowed, POST only, credentials off; header attached by the serviceYes
Handler codeRead ALLOWED_ORIGIN and set Access-Control-Allow-Origin on its responseYes
Both togetherTwo values in a header that permits oneBroken

Why it was easy to make

This class of bug is characteristic of platform features that overlap with application code. The Function URL's CORS panel presents itself as the way to configure CORS, and it is. The handler setting its own headers is also correct, and is what the code would need behind API Gateway or any other fronting service. Individually reasonable, jointly broken, and nothing in either interface warns about the other.

"Contains multiple values" is not a misconfigured origin. It is two things setting the same header, and one of them must stop.

Resolution

CORS was disabled on the Function URL, leaving the handler as the single authority. Ownership in code was chosen for two reasons: the permitted origin stays with the rest of the configuration in environment variables, and the header logic travels with the code if the endpoint is ever moved behind API Gateway. The reverse — deleting the header code and relying on the platform — would have been equally valid. What matters is that exactly one layer owns the header.

What it cost

Nothing. It happened on a test submission, and the recovery path told the truth. Had the client trusted the 200, the same bug would have produced a form that appeared to work perfectly while discarding every message it received — the kind of defect discovered months later, by accident, when someone mentions they wrote and never heard back.

07
Security

Where the boundary actually sits

No stored credentials, a public endpoint, and a deliberately small blast radius.

The single most important property of the design is that it holds no long-lived credentials. The function receives temporary credentials through its execution role, and that role can do one thing beyond writing its own logs. There is no access key in the client, the function source or the environment variables.

The endpoint itself is public. With auth type NONE, Lambda performs no authentication and the resource-based policy permits invocation by any principal. CORS restricts which origins a browser will allow to read the response; it does nothing whatsoever to stop a direct request from curl, a script or another server.

CORS protects the browser's users, not your endpoint. Describing it as access control is how gaps get shipped.
ControlImplementation
Credential containmentNo secret anywhere in the system; sending authorized through an execution role with AWS-issued temporary credentials
Least privilegeOne inline action, ses:SendEmail, on top of Lambda's logging baseline
Fixed destinationThe recipient comes from configuration, never from the request, so a caller cannot turn the function into a relay
Configuration hygieneAddresses and origin held in environment variables, encrypted at rest with the service key, absent from the source
Endpoint exposureAuth type NONE is an accepted risk; the realistic worst case is inbox spam and a Lambda bill, not disclosure or lateral movement
Publication hygieneThe endpoint hostname is withheld from all published material, so a theoretical exposure is not converted into an advertised one

Abuse protection, in order of effort

  • Set reserved concurrency on the function so a flood cannot scale without limit — a single setting that caps the worst-case bill
  • Add a honeypot field that legitimate users never fill in
  • Add a CAPTCHA or proof-of-work challenge to the form
  • Apply simple per-IP rate limiting inside the handler

The SES verification emails were a smaller lesson in the same vein. Their confirmation links carried an access key identifier and a request signature as query parameters. They were single-use and long expired, but automated notification emails routinely carry credential material and deserve the same care as anything else when screenshotted.

08
Operations

Nothing runs between submissions

The running cost rounds to zero, and the reason is structural rather than a pricing accident.

Lambda
Free tier
The free tier covers on the order of a million requests and 400,000 GB-seconds a month. A contact form sits far below it.
SES
Per thousand
Billed per thousand messages, historically around ten cents. At this volume it rounds to nothing.
S3 · CloudFront
Already paid
Required to host the site anyway; the form adds one script and occasional invalidations.
CloudWatch Logs
Watch this
Log groups default to no expiry — the one line item likely to grow unattended.

AWS pricing changes and varies by region, so these figures are an indication of magnitude rather than a quotation. The conclusion that matters does not depend on them: the design has no idle cost, because no component runs between submissions.

The deployment loop

Deployment is manual and has three steps: edit the client script, upload it to the site bucket, and create a CloudFront invalidation across all paths. Skip the last step and visitors keep receiving the cached previous version of the script, which makes a correct fix look like a failed one. Manual is acceptable at this scale, and it is the obvious first candidate for automation.

09
Build

The running system

Captured during the build. Account identifiers, credentials, personal contact details and the endpoint hostname are redacted; screenshots are cropped to the relevant panel.

Architecture diagram of the serverless contact form
ArchitectureThe request path, the response contract the client relies on, the failure path, and the single-action execution role.
SES create identity screen with a DMARC warning
Identity choiceAddress verification selected over domain verification, with the DMARC warning that section 10 returns to.
Lambda environment variables with addresses redacted
ConfigurationThe permitted origin, sender and recipient, lifted out of the source.
IAM inline policy granting ses:SendEmail
PermissionThe whole grant: one action, written directly as JSON.
Function URL CORS settings
The first CORS layerEvery value correct in isolation — one origin, content-type, POST, no credentials.
The contact.js client script with the endpoint redacted
Client contractThe script's own comments record the two rules: say plainly when it cannot send, and never treat a 200 as delivery.
Function URL configuration with CORS disabled
The fixCORS disabled on the Function URL, leaving the handler as the single owner of the header.
Live contact page showing the recovery panel and a CORS error in the console
Incident, and the safety netThe console names a duplicated header while the form tells the visitor the truth and offers a way to send anyway.
Delivered contact-form email with personal fields redacted
Definition of doneA message from a static site, delivered intact. The "via amazonses.com" annotation is the authentication gap in section 10.
10
Status

What works, and what is weak

The honest list, in the order it should be addressed.

The most consequential open issue

The FROM address belongs to a public mail provider whose domain the account does not control. SES cannot produce SPF or DKIM alignment for a domain it has no authority over, so outgoing mail fails DMARC alignment at the receiving end. The delivered message shows the symptom: recipients see it annotated as sent via the SES domain. Mail in that state is more likely to be filtered as spam, and strict recipients may reject it.

The fix is well understood. Verify the portfolio's own domain as an SES identity, publish the DKIM records SES provides, send from an address at that domain, and put the visitor's address in Reply-To so replies still reach them while the sender stays on a domain the account controls. The function's own source comments already describe this as the correct approach, which makes it a known shortcut rather than an unknown flaw.

Naming the weakest decision clearly is more useful than a report that claims everything went perfectly.
CapabilityStateNote
End-to-end deliveryShippedBrowser, Function URL, handler, SES, inbox — every field intact
Credential-free designShippedExecution role with temporary credentials; nothing stored
Single-action permissionShippedInline ses:SendEmail; resource still a wildcard
Response contract & recovery pathShippedProven under a real failure on the first live submission
CORS ownershipShippedHandler is the single authority for the origin header
Sending identityPrototypeDelivers, but fails DMARC alignment; domain identity with DKIM is the first change
Abuse protectionDeferredReserved concurrency, honeypot, CAPTCHA or proof-of-work, per-IP rate limiting
Scoped IAM resourceDeferredIdentity ARN plus a condition on the permitted From address
AlarmsDeferredCloudWatch alarm on function Errors, plus SES bounce and complaint rates
Log retentionDeferredLog groups currently accumulate indefinitely
Runtime pinningDeferredCreated on a preview Node.js runtime; pin a generally available version
SES production accessDeferredPrerequisite for acknowledgement emails to visitors
Deployment automationDeferredEdit, upload, invalidate is manual today

One housekeeping item rounds out the list: the handler's header comment still describes an API Gateway deployment from an earlier plan. Stale comments are small, and they cost real time later.

What this build demonstrates

Small, disciplined judgements


The engineering judgements that hold up best here are the small ones: a single IAM action instead of a convenient managed policy, configuration lifted out of source, and the refusal to treat an HTTP 200 as proof of delivery. That last one is what turned a production CORS bug from a silent, message-losing failure into a visible, recoverable error, and it is the decision most worth carrying into future work.

The weakest judgement is the sending identity. It was the right way to reach a working end-to-end path quickly, it leaves email authentication misaligned, and it is the first thing to change.


Amazon SES · AWS Lambda · Lambda Function URL · IAM · S3 · CloudFront · CloudWatch · Node.js

Read the article →  ·  All project details  ·  Portfolio & resume  ·  LinkedIn