Every browser-to-Lambda surface in this account passes through three independent enforcement layers, owned by three different teams, failing in three different ways. Each layer holds independently; together they are the substrate's app-side defense doctrine.
When the obs vibe app's first deploy got rejected by an org-level SCP (no public Lambda Function URLs), we could have asked for an exemption. Instead we adopted the SCP-compliant pattern: CloudFront Origin Access Control signing requests to a Lambda Function URL with AuthType = AWS_IAM. The signing is owned by CloudFront, enforced by the Lambda service. Combined with the application-level JWT check we already had, we ended up with three layers: the org's SCP rules out the wrong-shape Function URL at the boundary; CloudFront's OAC rules out non-CloudFront callers at the transport; the Lambda code rules out non-PayCargo identities at the application. Every layer answers a different question. None of them depend on the others holding.
Single-layer auth is fragile because one bug breaks everything. The substrate already enforces this at the network layer: Network Firewall blocks egress, DNS Firewall blocks resolution, VPC endpoint policies block service-level calls. Three independent enforcement layers, three different teams, three different failure modes.
At the app layer we had only one: in-function JWT validation. That meant:
The SCP rejection forced us to add two more layers above the application. The result is the doctrine we should have started with: three independent layers, three different operator concerns.
What it does: The PayCargo AWS Organization (master account 504711782668) enforces a Service Control Policy that denies lambda:CreateFunctionUrlConfig and lambda:UpdateFunctionUrlConfig when the FunctionUrlAuthType parameter is NONE. Every child account, including AI-sandbox (959228203854), inherits this denial.
What it catches: Any vibe app, anywhere in the org, that tries to expose a Lambda directly to the internet without IAM-based access control. The org doesn't allow it — the API call fails at the org boundary before the resource is created.
Why this is the right policy: A public Function URL is an exposure surface that bypasses every IAM convention the org otherwise enforces. The bypass might be intentional (a chatbot, a webhook) but it should require explicit, documented justification — not be the path of least resistance. The SCP makes the path of least resistance the secure path.
What this layer canNOT prevent: A misconfigured CloudFront distribution forwarding to a Function URL that does have AWS_IAM auth, but where the resource policy is too permissive. That's what layer 2 is for.
What it does: The Lambda Function URL is configured with AuthType = AWS_IAM. A CloudFront Origin Access Control resource (origin_access_control_origin_type = "lambda") is attached to the lambda-backend origin on the distribution. CloudFront signs every request to the Function URL with SigV4 using cloudfront.amazonaws.com as the principal.
The Lambda function's resource policy grants both lambda:InvokeFunctionUrl AND lambda:InvokeFunction to cloudfront.amazonaws.com, scoped via the AWS:SourceArn condition to the specific CloudFront distribution ARN. Other CloudFront distributions in this account — or in any other account — cannot invoke our Function URL because the source ARN doesn't match.
The official AWS docs for CloudFront OAC to Lambda show a sample policy with only lambda:InvokeFunctionUrl. In practice, that's not enough — CloudFront OAC's SigV4 signature also names lambda:InvokeFunction, and the Function URL service denies the request if either action is missing. We discovered this empirically by isolating each action: InvokeFunctionUrl alone produced 403; InvokeFunction alone produced 403; both together let the request through to Lambda code. Both go into the resource policy. The doctrine doc and the substrate Terraform reflect this.
What it catches: Anyone hitting the Lambda Function URL directly (the *.lambda-url.us-east-1.on.aws hostname) with curl, Postman, or any other tool. The Function URL requires SigV4 signing. Only CloudFront's OAC can produce that signature for our distribution. Direct callers get HTTP 403 with the exact error message we saw during diagnosis.
Why this layer is necessary even with the SCP: The SCP rules out AuthType = NONE. It doesn't rule out AuthType = AWS_IAM with a too-permissive resource policy. Layer 2 ties the IAM authorization to the specific CloudFront distribution, not any caller with valid AWS credentials.
What this layer cannot prevent: A user with a valid Cognito JWT who is NOT supposed to have access to a specific route. The OAC validates that "this request came from our CloudFront." It does not validate "this user is allowed to call /api/admin/users." That's what layer 3 is for.
What it does: The Lambda code validates an X-Auth-Token header against the Cognito user pool's JWKS endpoint using the aws-jwt-verify library. Invalid or expired tokens get rejected with HTTP 401 + a JSON error body. Valid tokens proceed to the route handler.
For admin routes (/api/admin/*), the Lambda additionally checks the cognito:groups claim on the token. Users without the admins group get HTTP 403.
What it catches: A user without an active Cognito session. A user whose token expired. A user who is in Cognito but not in the right group for the route they're calling. Forged tokens (rejected by JWKS signature verification). Tokens from the wrong user pool (rejected by issuer check). Tokens for the wrong app client (rejected by audience check — the verifier is initialized lazily with our discovered client ID).
Why this layer is necessary even with layers 1 and 2: Layers 1 and 2 enforce that the request came from CloudFront. CloudFront accepts requests from anyone on the public internet and forwards them. So a request reaching Lambda has passed the transport-layer checks but says nothing about WHO is making it. The JWT check answers "who is this caller, and are they allowed to do this?"
What this layer cannot prevent: A bug in the route handler that does the wrong thing AFTER successful auth. That's what code review and testing are for. Auth is a different concern from authorization-policy-on-business-logic.
One signed-in user clicking a tab in the obs vibe app, all the way through:
Three checkpoints, three different teams, three different failure messages. Easy to triage which layer rejected by looking at the response.
CloudFront OAC's SigV4 signing for Lambda Function URLs uses the Authorization header to carry the signature. If your SPA sends its JWT in Authorization: Bearer ..., OAC overwrites or corrupts the signature, the Function URL returns 403, and the diagnosis chain is opaque (you see the same generic Forbidden response whether the issue is the SCP, the resource policy, or the header conflict).
Industry-standard fix: pick a different header for the application's auth token. We use X-Auth-Token. The SPA's fetch() sends:
headers: {
'X-Auth-Token': `Bearer ${getAccessToken()}`,
}
And Lambda reads:
const authHeader =
event.headers?.['x-auth-token'] ||
event.headers?.['X-Auth-Token'] || '';
The Lambda Function URL CORS configuration must include x-auth-token in allow_headers; AWS strips any unallowed header before forwarding. For symmetry we also include content-type for POST bodies.
| Attack / mistake | Caught by | Response |
|---|---|---|
Developer creates a new Lambda with public Function URL (AuthType = NONE) |
Layer 1 (org SCP) | Terraform apply fails. Resource never created. |
| Anyone tries to invoke the Lambda URL directly without SigV4 signing | Layer 2 (CloudFront OAC) | HTTP 403 with "Function URL authorization" message. |
| A different CloudFront distribution in this account tries to forward to our Lambda | Layer 2 (resource policy SourceArn condition) | HTTP 403. SourceArn condition won't match. |
An attacker without a Cognito account tries to call /api/operators/health |
Layer 3 (JWT validation) | HTTP 401 with JSON body { error: "Missing X-Auth-Token header" }. |
A regular user (not in admins) tries to call /api/admin/users |
Layer 3 (group claim check) | HTTP 403 with JSON body { error: "Admin access required" }. |
| An expired Cognito token replayed | Layer 3 (JWT exp claim) | HTTP 401. SPA's auto-refresh kicks user back to Hosted UI. |
| A token forged with a different signing key | Layer 3 (JWKS signature check) | HTTP 401. Signature does not validate against the user pool's published JWKS. |
| A token issued for a different Cognito user pool | Layer 3 (issuer check) | HTTP 401. iss claim does not match our expected issuer URL. |
| Application bug: a route handler does the wrong thing after auth | None of these layers | Caught by code review, testing, and the audit trail in CloudWatch / CloudTrail. |
The right-most column is the operator's signal — the shape of the failure tells you which layer rejected and which team should triage. A generic 403 with the AWS error message means layer 2; a JSON 401 means layer 3; a Terraform apply error means layer 1.
This pattern is the canonical browser-to-Lambda design for every future vibe app. The decisions made here do not need to be re-made:
AuthType = AWS_IAM. Never NONE. The org SCP enforces this regardless.origin_access_control_origin_type = "lambda". Per-distribution OAC; do not share OACs across distributions.cloudfront.amazonaws.com service principal with both lambda:InvokeFunctionUrl and lambda:InvokeFunction, each scoped by AWS:SourceArn to the specific distribution. AWS docs only mention the first; the second is required in practice.X-Auth-Token. Never Authorization. CORS allow_headers must include x-auth-token.allow_methods: only GET and POST (each method name must be ≤ 6 chars by AWS validation, so OPTIONS is auto-handled and never listed).allow_origins: ["*"]. The defense-in-depth means Origin enforcement is not the right boundary here; OAC and JWT are.The vibecode SDK will ship a module template (vibecode/sdk/app-template/browser-lambda.tf) that wires all of this once. Future vibe apps consume the template; they do not redo the decisions.
The browser-to-Lambda pattern is captured in MODULES.md as part of the vibecode SDK roadmap. Specifically:
vibecode/sdk/app-template/browser-lambda.tf — Terraform module that takes (app name, lambda source dir, JWT issuer config) and emits a complete browser-to-Lambda stack: CloudFront distribution, OAC for both S3 and Lambda, Function URL with AWS_IAM auth, Lambda resource policy, IAM execution role with permission boundary, CloudWatch log group on the correct governance CMK. Wires Step.4.5.a (data governance grid) and Step.4.5.b (three-layer defense) doctrines into one consumable.vibecode/sdk/lambda/jwt.ts — Lambda runtime helper that reads X-Auth-Token, calls aws-jwt-verify with the discovered client ID, and exposes the parsed claims (including cognito:groups) to the route handler. Standardizes the application-layer auth check.vibecode/sdk/spa/auth-fetch.ts — browser-side helper that wraps fetch() with the X-Auth-Token header, token-expiry checks, and signOut-on-401 semantics. Standardizes the browser-side auth-aware client.Future vibe apps consume the template by declaring an app_name, a governance_cell (from Step.4.5.a), and a routes manifest. The substrate handles the rest.
This step encodes the substrate's defense-in-depth doctrine extended into the application layer. The substrate already enforced this at network and identity layers:
The unifying principle: every layer enforces independently. No layer trusts that the layers above caught everything. This is the same principle that made the network layer work; we just extended it up the stack.
Three more doctrines specific to this step:
X-Auth-Token. Apps don't get to choose. The header name is part of the platform's API contract.