PAYCARGO · AI-SANDBOX · PROCESS

4.5.bThree-layer defense for browser → Lambda

The canonical pattern for any vibe app whose browser frontend calls a Lambda backend. Three independent enforcement layers — org SCP, transport auth (CloudFront OAC for GET-only, API Gateway + shared secret for POST/PUT/PATCH), application JWT — owned by three different teams and failing in three different ways. The substrate's "defense-in-depth" doctrine extended from network into transport and application.
Block: 4.5.b (companion to 4.5.a; both are substrate patterns surfaced by the obs vibe app) · Operator: Pattern doctrine, applied at every vibe app boundary · Cost: $0/month (the layers exist whether you use them or not) · Permanence: Permanent — the browser-to-backend pattern for every future vibe app
Documented

State: Pattern proved out by the obs vibe app deploy. Codified here as the reusable browser-to-Lambda design for vibe apps 2 and 3.

Why this exists: The obs app's first deploy used Lambda Function URL with AuthType = NONE — the simplest pattern, no IAM signing needed. PayCargo's AWS org rejected it via SCP. The rejection forced us to discover a better pattern: CloudFront Origin Access Control for Lambda. After implementing it we noticed it gives us three independent enforcement layers, each owned by a different operator concern. Worth pinning as doctrine.

Doctrine update · 2026-06

Layer 2 mechanism split into two variants. The chatbot cartridge surfaced a documented AWS bug: CloudFront OAC + Lambda Function URL has a body-hash mismatch on every POST/PUT/PATCH. OAC's canonical request and Function URL's receive-side validation hash the request body differently; SigV4 fails with "signature does not match" on any non-GET. The CloudFront-Function workaround that pins x-amz-content-sha256: UNSIGNED-PAYLOAD is overridden by OAC. Confirmed unfixable from our side.

The three-layer doctrine still holds. Only Layer 2's mechanism changes when the backend handles POST. Layers 1 (org SCP) and 3 (Cognito JWT in app code) are unchanged. The split:

  • GET-only Lambda backends → keep the OAC + Function URL pattern documented below. It works, it's simpler, and it stays.
  • Any backend that handles POST/PUT/PATCH → use the new variant: CloudFront origin points at an API Gateway HTTP API, and Layer 2 is enforced by an X-Origin-Secret shared header injected via CloudFront's origin custom_header block. Lambda rejects any request without the matching secret. The API Gateway endpoint is public but unusable without the header (verified: direct hits return 403). A Terraform random_password generates the secret, threaded to both sides at apply time.

First implementation: the chatbot cartridge (cartridges/global/chatbot/). Function URL + OAC removed; API Gateway HTTP API added; random_password.origin_secret threaded into Lambda env and the CloudFront origin custom_header block. Direct API Gateway endpoint hits return 403. Verified end-to-end. Cost delta: ~$1 per million requests (API Gateway HTTP API), trivial at internal scale.

What this does NOT change: the org SCP still bans public Function URLs (AuthType = NONE), the JWT verification in handler code is unchanged, and the data governance CMK grid from 4.5.a is unchanged. The egress allowlist and "single egress point" doctrine are unaffected — API Gateway is still behind CloudFront in practice.

Next implementation lift: when the platform extracts a reusable cartridge backend module, the API Gateway + shared-secret pattern becomes the default; the OAC pattern stays available for GET-only cartridges as a thinner-cost alternative.

Executive Summary

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.

1. Why three layers, not one

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.

2. Layer 1: AWS Org SCP

Layer 1 · Org SCP
owner: security / platform leadership

No public Lambda Function URLs in the org

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.

3. Layer 2: CloudFront Origin Access Control for Lambda

Layer 2 · CloudFront OAC
owner: platform team (infra)

Only CloudFront-signed requests reach the Function URL

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.

AWS docs are wrong about which actions are needed

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.

4. Layer 3: Application JWT validation

Layer 3 · Application
owner: app team

Only Cognito-issued tokens dispatch to route handlers

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.

5. Full request flow

One signed-in user clicking a tab in the obs vibe app, all the way through:

Browser (SPA, signed-in) │ │ GET https://d2kh3b2wl3msbg.cloudfront.net/api/operators/health │ X-Auth-Token: Bearer eyJhbGc... ← Cognito JWT (the application's auth) │ (no Authorization header from the SPA) ▼ CloudFront edge (us-east-1) │ Match /api/* → ordered_cache_behavior → lambda-backend origin │ OAC signs: │ Authorization: AWS4-HMAC-SHA256 ← SigV4 signature (the transport auth) │ Credential=…/cloudfront/aws4_request │ Signed-headers include host │ Origin request policy AllViewerExceptHostHeader forwards X-Auth-Token through ▼ Lambda Function URL (AuthType = AWS_IAM) │ Validates SigV4: signature, principal cloudfront.amazonaws.com, │ aws:SourceArn matches our distribution ARN │ Passes resource-policy condition lambda:FunctionUrlAuthType == AWS_IAM │ Strips Authorization (SigV4 is for transport, not for the function) ▼ Lambda execution (handler.js) │ Reads X-Auth-Token from event.headers │ getJwtVerifier() → CognitoJwtVerifier.verify(token) │ ├─ JWKS sig check │ ├─ Issuer == cognito-idp.us-east-1.amazonaws.com/<userPoolId> │ ├─ Audience == our app client ID (discovered at init) │ ├─ token_use == "access" │ └─ exp > now │ Extract cognito:groups claim if route requires admin ▼ Route dispatch → CloudWatch metrics → JSON response

Three checkpoints, three different teams, three different failure messages. Easy to triage which layer rejected by looking at the response.

6. The OAC + Authorization header conflict

Gotcha worth pinning

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.

7. Failure modes — what each layer catches

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.

8. Reusable for vibe apps 2 and 3

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:

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.

9. Integration with the Vibe Code SDK

The browser-to-Lambda pattern is captured in MODULES.md as part of the vibecode SDK roadmap. Specifically:

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.

10. Doctrine

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: