Skip to content

Part 7 of 7 · Form spam filter series ~7 min read

Engineering reference: the form spam filter architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the one table, the signal collection, and the narrow model band.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 1 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The form spam filter drawn with AWS service namesThree boxes across the top outside the AWS account. The form, served as static files from S3 behind CloudFront. Thresholds, read through the Google Sheets API read-only. And SES outbound, carrying delivered enquiries and the review batch. Inside the account, three groups. A Function URL for submissions and SQS carrying one scoring queue. Three Lambda functions named submit, score and review. And a single DynamoDB table named submissions. A note gives the region as us-east-1, one account, and states that nothing in this system deletes a submission.AWS ACCOUNTThe formCloudFront + S3ThresholdsSheets API, read-onlySES outboundenquiries, review batchFunction URL + SQSsubmit,one scoring queueLambda x3submit, score, reviewDynamoDB x1submissionsingroundsoutus-east-1. One account. Nothing in this system deletes a submission.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • App integration
  • Networking
  • Front-end & mobile

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
fs-submitFunction URLAccepts the post, captures the signals, enqueues; always returns success10s / 512 MB
fs-scoreSQS scoring queueCheap signals, then one Bedrock call for the middle band only20s / 512 MB
fs-reviewEventBridge 11am/4pm + Function URLBuilds the review batch; handles the two buttons; the 4-hour timeout20s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
fs-submit-roledynamodb:PutItem, sqs:SendMessageThe submissions table; the scoring queue
fs-score-rolebedrock:InvokeModel, dynamodb:UpdateItem, ses:SendEmailOne model arn; submissions; one verified identity
fs-review-roledynamodb:Query/UpdateItem, ses:SendEmailSubmissions; one verified identity

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: submissions

PK   submission_id     S   sub_2026_08_01_7f2a
     received_at       S   2026-08-01T10:14:02Z
     fields            M   {name, email, phone, message}
     signals           M   {honeypot, fill_ms, field_order, links,
                            consistency, body_hash}
     cheap_score       N   0.62
     model_called      BOOL true
     model_specific    N   0.71   — null if not called
     destination       S   inbox | review | quarantine
     reviewed_as       S   real | spam  — set by a person
     delivered_at      S   set when it reaches the inbox
     ttl               N   quarantine: +90 days; real: none

GSI  body-hash-index     PK body_hash      — the repetition check
GSI  destination-index   PK destination, SK received_at  — the review batch
GSI  email-index         PK email          — the quarantine search

Every submission is stored with its signals, which is what makes threshold
replay possible: a proposed change can be run over three months of real
traffic before it goes live.

Inbound and outbound

  • The form is static files in S3 behind CloudFront, posting to a Function URL. There is no CAPTCHA and no third-party script.
  • The honeypot field is hidden with CSS and removed from the accessibility tree, with autocomplete=“off” and a name browsers will not autofill. Getting this wrong silently classifies screen reader users as bots.
  • Fill time is measured from a timestamp written into the page at render, signed so it cannot be forged, with a floor and deliberately no ceiling.
  • The endpoint always returns success, whatever the classification. Telling a bot it was filtered is telling a bot how to adapt, and telling a person their genuine enquiry was rejected is worse.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, asked one question about roughly one submission in ten.
  • The prompt carries the message body only. Not the name, the address, the IP or the country — those are rule inputs, and putting them in a model prompt invites judgements about people based on where they are.
  • Output is a JSON schema with a specificity score and a confidence. It never returns a destination, because the mapping from evidence to outcome is a business threshold.
  • A model failure routes to review, never to quarantine. The band exists because the cheap signals could not decide, so falling back to them is falling back to a shrug.
  • The band is rate-limited per hour. A spam flood must not become a Bedrock bill; overflow goes to review, which is the safe direction.

Things worth knowing before you build it

  • Hide the honeypot from assistive technology, not just visually. An off-screen labelled field gets filled in by screen reader users and silently classifies them as bots.
  • Put a floor on fill time and no ceiling. Somebody opens the form, takes a phone call, and submits forty minutes later.
  • Always return success to the submitter. A rejection message teaches bots and insults people.
  • Never delete. The searchable quarantine is the only way a false positive ever becomes visible.
  • Store every signal with every submission. Threshold changes should be replayed against three months of real traffic before they go live, and that is only possible if the inputs were kept.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts