Deploying a Serverless Intake System: Solving AWS Lambda Function URL Permission Gotchas
This post covers the deployment of a serverless form-intake backend for DragonBodyGuards.com, including the root cause of a two-day 403 authentication failure and the lessons learned about AWS Lambda resource policies.
What Was Built
The goal was to replace a mailto: form submission with a real backend that captures intake requests, stores them durably, and notifies administrators via email. The architecture is minimal but complete:
- Frontend: Static site served from S3 + CloudFront (dragonbodyguards.com)
- API: AWS Lambda function named
dbg-onboarding-apiwith a public Function URL - Storage: DynamoDB table for intake records
- Notifications: Amazon SES for transactional email to dispatch@dangerouscentaur.com
- Orchestration: CloudFormation (via deployment script) for reproducible infrastructure
The frontend HTML wires the form to the Lambda endpoint via window.DBG_ENDPOINT, and JavaScript submits form data as JSON POST requests.
The AWS Lambda Permission Bug
After the initial deployment script ran, the public Lambda Function URL returned persistent 403 Forbidden errors — even though the endpoint was resolvable. This looked like a propagation delay or misconfiguration, but repeated retries didn't help.
The root cause: AWS changed the Lambda authorization model in October 2025. Public Function URLs now require two resource-policy permissions, not one:
lambda:InvokeFunctionUrl— allows calling the Function URL itselflambda:InvokeFunction— allows invoking the underlying function
The deployment script originally granted only the first permission. When clients hit the public URL, AWS checked both permissions, found the second missing, and returned 403 — indistinguishable from a transient error.
Technical Details: Architecture and Integration
The Lambda function is Python-based and executes this flow:
- Receives JSON POST from the frontend form
- Stores the record in a DynamoDB table (with request ID and timestamp)
- Sends a notification email via SES to the Dragon dispatch address
- Returns 200 with a request ID for the client to display
The Lambda's execution role includes inline policies for:
dynamodb:PutItemon the intake tableses:SendEmailfor outbound notificationslogs:CreateLogGroup,logs:CreateLogStream,logs:PutLogEventsfor CloudWatch
The resource-based policy (attached to the Lambda itself) grants:
{
"Effect": "Allow",
"Principal": "*",
"Action": [
"lambda:InvokeFunctionUrl",
"lambda:InvokeFunction"
],
"Resource": "arn:aws:lambda:us-east-1:*:function:dbg-onboarding-api"
}
Note the Principal: "*" and unrestricted resource ARN — this intentionally makes the endpoint public. No API keys or auth tokens are needed for the form submission.
Frontend Wiring
The static site's index.html was modified to:
- Define
window.DBG_ENDPOINTat the top of the page (injected by the deployment script) - Attach a submit handler to the form that posts JSON to the Lambda
- Display a success banner with the request ID on 200
- Display error messages on non-2xx responses
The original index.html was backed up before modification, and the CloudFront cache was invalidated (/* pattern) after uploading the new version to S3.
Deployment Script and Verification
The canonical deployment script is located at:
/Users/cb/icloud-jada-ops/state/dbg-intake-staging-2026-07-05/finish-deploy.sh
It orchestrates:
- CloudFormation stack creation/update for Lambda, DynamoDB, and IAM roles
- Lambda function code upload from S3
- Function URL creation with public access
- Resource policy attachment (with both required permissions)
- index.html patching and upload to S3
- CloudFront cache invalidation
- End-to-end smoke test (HTTP GET on the site, form submission via curl)
Testing confirmed the intake pipeline end-to-end:
- Public form endpoint returns HTTP 200 with valid request IDs
- Submitted data appears in DynamoDB logs
- Notification emails arrive at dispatch@dangerouscentaur.com within seconds
- The live site at dragonbodyguards.com renders correctly and the form is wired
Key Decisions
Why public Function URLs instead of API Gateway? Function URLs have less operational overhead — no separate API stage management, CORS configuration, or request/response transformations. For a simple stateless endpoint, they're simpler and cheaper.
Why DynamoDB instead of RDS? No need for schema migrations or connection pooling. DynamoDB scales automatically and fits the intake use case (write-heavy, read-heavy for admin dashboards, no joins). On-demand billing means minimal cost during low-traffic periods.
Why SES instead of SNS or third-party email? Direct control over sender address (dispatch@dangerouscentaur.com), no subscription management, and compliance-friendly audit trails in CloudWatch Logs.
What's Next
- CA PPO Licensure: Legal review of DragonBodyGuards' unarmed presence-only positioning in California. This gates commercial operations.
- Automated Vetting: Integration with Checkr or Yardstik for background checks on Dragon candidates. Currently blocked pending account setup.
- One-City Demand Test: Initial market launch in a single city to validate demand before scaling nationally.
- Commercial Render: AI influencer video generation (fal.ai account pending) to create IG content for brand awareness.
The intake system is now the source of truth for new requests. All future Dragons and bookings flow through this pipeline.
```