Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
title: "Fronting a tRPC Lambda with sst.aws.Router: path prefix and POST signing gotchas"
date: 2026-04-28
category: integration-issues
module: infra/api
problem_type: integration_issue
component: tooling
symptoms:
- "tRPC error: No procedure found on path \"api/<procedure>\" (HTTP 404, code NOT_FOUND)"
- "Lambda POST requests fail with \"The request signature we calculated does not match the signature you provided\" (SigV4 SignatureDoesNotMatch 403)"
- "Only happens after moving an existing url:true Lambda behind sst.aws.Router; GET-only flows may appear to work"
root_cause: config_error
resolution_type: code_fix
severity: high
tags:
- sst
- sst-router
- trpc
- lambda-function-url
- oac
- sigv4
- path-rewrite
---

# Fronting a tRPC Lambda with sst.aws.Router: path prefix and POST signing gotchas

## Problem

When putting an existing `sst.aws.Function` (with `url: true`) behind an `sst.aws.Router` — e.g. to attach a WAF WebACL or unify web + API under a single CloudFront — two integration issues surface after deploy that are invisible at typecheck time:

1. POST requests fail with a SigV4 `SignatureDoesNotMatch` 403 when `protection: "oac"` is used on the Router.
2. All tRPC procedure lookups return `NOT_FOUND` because the Router's path prefix (e.g. `/api`) is forwarded verbatim to the Lambda, and tRPC treats the full path as the procedure name.

Both issues only appear on a real deploy — they are silent in `sst deploy --diff` and pass TypeScript checks.

## Symptoms

- Web client calls to the API return a tRPC error:
```json
{
"error": {
"message": "No procedure found on path \"api/transaction.deposit\"",
"code": -32004,
"data": { "code": "NOT_FOUND", "httpStatus": 404, "path": "api/transaction.deposit" }
}
}
```
- Before the path-prefix fix, POSTs also returned: `"The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method."`
- GET requests through the Router may succeed on their own, masking the POST-signing issue until a mutation is attempted.
- Direct Function URL access (`*.lambda-url.<region>.on.aws`) returns 403 from AWS — this is OAC working as intended and not the bug.

## What Didn't Work

- **Assuming `protection: "oac"` was the right choice.** SST's own docs call it out as "suitable when you control all POST requests" — meaning clients must manually send `x-amz-content-sha256` with the body hash. The tRPC HTTP client does not do this, so every mutation fails SigV4 verification.
- **Assuming the Router's `path: "/api"` argument strips the prefix before forwarding.** It does not. The Router uses the prefix for routing only; the full original path is what reaches the origin (same behavior as any CloudFront path-based behavior mapping).
- **Configuring the tRPC router with an `api` namespace.** tRPC procedure paths are dot-separated, not slash-separated, so `api/transaction.deposit` is not a lookup shape any router configuration can satisfy.

## Solution

Two changes, both small, both required.

### 1. Use `protection: "oac-with-edge-signing"`, not `"oac"`

In `sst.config.ts`:

```ts
const router = new sst.aws.Router('Main', {
// "oac-with-edge-signing" (not plain "oac") so POST requests work.
// "oac" alone requires every client to send x-amz-content-sha256 on
// each POST; edge-signing adds a Lambda@Edge that handles it for us.
protection: 'oac-with-edge-signing'
})
```

`oac-with-edge-signing` provisions a Lambda@Edge in us-east-1 that signs every request (including the body) before forwarding to the Function URL. Tradeoffs:

- First deploy adds ~2–5 min while Lambda@Edge replicates to edge POPs.
- ~10–50 ms extra latency per request (negligible for internal tooling).
- Teardown (`sst remove`) can lag because CloudFront releases Lambda@Edge functions asynchronously — retry after a wait if you see an "in use" error.

### 2. Strip the Router path prefix inside the Lambda handler

`infra/src/apiHandler.ts`:

```ts
import type {
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
Context
} from 'aws-lambda'
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda'

const ROUTER_PREFIX = '/api'

const trpcHandler = awsLambdaRequestHandler({
router: appRouter,
createContext: (opts) => opts
})

export const handler = async (
event: APIGatewayProxyEvent | APIGatewayProxyEventV2,
context: Context
) => {
const v2 = event as APIGatewayProxyEventV2
if (v2.rawPath?.startsWith(ROUTER_PREFIX)) {
v2.rawPath = v2.rawPath.slice(ROUTER_PREFIX.length) || '/'
if (v2.requestContext?.http?.path) {
v2.requestContext.http.path = v2.rawPath
}
}
const v1 = event as APIGatewayProxyEvent
if (v1.path?.startsWith(ROUTER_PREFIX)) {
v1.path = v1.path.slice(ROUTER_PREFIX.length) || '/'
}
return trpcHandler(event, context)
}
```

Keep `ROUTER_PREFIX` in lockstep with the `path` used when attaching the Function to the Router in `infra/api.ts`:

```ts
// infra/api.ts
export function createApi(router: sst.aws.Router) {
return new sst.aws.Function('tRPCAPI', {
url: { router: { instance: router, path: '/api' } },
// ...
})
}
```

## Why This Works

**POST signing:** AWS Lambda Function URLs with `authType: AWS_IAM` enforce SigV4 verification. The signature covers headers, HTTP method, path, and — critically — the SHA-256 of the request body via the `x-amz-content-sha256` header. Plain `oac` lets CloudFront forward the request with OAC-level signing of headers, but it does not compute the body hash automatically; any POST with a body fails verification. The `oac-with-edge-signing` Lambda@Edge reads the body, computes the hash, adds the correct `x-amz-content-sha256` header, and re-signs the request before it reaches the Function URL.

**Path prefix:** CloudFront path-based cache behaviors use the pattern to decide *which* origin to route to, not what path to forward. The origin receives the original URL path unchanged. tRPC's `awsLambdaRequestHandler` derives the procedure name from `event.rawPath` (V2) or `event.path` (V1) — so `/api/transaction.deposit` becomes a lookup for the literal procedure `api/transaction.deposit`, which does not exist. Stripping the prefix in the handler restores the shape tRPC expects, and keeps the wiring between the Router path and the tRPC router decoupled — the client keeps calling `${router.url}/api/...` and tRPC keeps seeing procedure names without the prefix.

## Prevention

- **Always deploy and exercise a POST path when fronting a Lambda Function URL with `sst.aws.Router`.** Typecheck, `sst diff`, and even a GET smoke test will not catch either of these. Pair the deploy with at least one mutation through the full Router → Function URL path.
- **Default to `protection: "oac-with-edge-signing"`** unless you have a specific reason to require client-side SigV4 hashing (e.g. a small number of backend-to-backend callers you fully control). The cost/latency difference is small; the correctness guarantee is large.
- **Treat the Router path and handler prefix as a shared contract.** If you change `path: '/api'` in the Function's `url.router` config, update `ROUTER_PREFIX` in the handler in the same commit. A comment in each file pointing at the other is enough.
- **Avoid the failure mode when possible by mounting the Lambda at `/`.** If the Router only fronts the API (no Web), use `path: '/'` and skip the handler-side stripping entirely. This repo shares the Router with the Web StaticSite, so `/api` is needed to separate the two surfaces — but that's a deliberate choice, not a default.

## Related Issues

- SST docs on Router `protection` modes: `oac` vs `oac-with-edge-signing` vs `none` — explicitly documents the POST `x-amz-content-sha256` requirement for plain `oac`.
- See also: `docs/plans/2026-04-28-001-feat-sst-router-unify-frontend-api-plan.md` — the plan that introduced the Router in this template, including the `oac-with-edge-signing` choice and the prefix-stripping shim.
13 changes: 8 additions & 5 deletions infra/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { depositStateMachine } from '../domains/transaction/features/deposit/stack'
export const tRPCAPI = new sst.aws.Function('tRPCAPI', {
url: true,
link: [depositStateMachine],
handler: 'infra/src/apiHandler.handler'
})

export function createApi(router: sst.aws.Router) {
return new sst.aws.Function('tRPCAPI', {
url: { router: { instance: router, path: '/api' } },
link: [depositStateMachine],
handler: 'infra/src/apiHandler.handler'
})
}
40 changes: 35 additions & 5 deletions infra/src/apiHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
awsLambdaRequestHandler,
type CreateAWSLambdaContextOptions
} from '@trpc/server/adapters/aws-lambda'
import { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda'
import type {
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
Context
} from 'aws-lambda'

type LambdaContextOptions = CreateAWSLambdaContextOptions<
APIGatewayProxyEvent | APIGatewayProxyEventV2
Expand All @@ -21,10 +25,36 @@ export const appRouter = createAppRouter({

export type AppRouter = typeof appRouter

/**
* Lambda handler for tRPC API
*/
export const handler = awsLambdaRequestHandler({
// Shared contract with `infra/api.ts`'s `url.router.path: '/api'`.
const ROUTER_PREFIX = '/api'

const trpcHandler = awsLambdaRequestHandler({
router: appRouter,
createContext: (opts: LambdaContextOptions) => opts
})

/**
* Lambda handler for tRPC API.
*
* The SST Router attaches this Lambda at `/api/*` and forwards the full path
* (including the `/api` prefix) to the Function URL. tRPC uses the path as
* the procedure name, so the prefix must be stripped here before delegating.
*/
export const handler = async (
event: APIGatewayProxyEvent | APIGatewayProxyEventV2,
context: Context
) => {
const v2 = event as APIGatewayProxyEventV2
if (v2.rawPath?.startsWith(ROUTER_PREFIX)) {
v2.rawPath = v2.rawPath.slice(ROUTER_PREFIX.length) || '/'
if (v2.requestContext?.http?.path) {
v2.requestContext.http.path = v2.rawPath
}
}
const v1 = event as APIGatewayProxyEvent
if (v1.path?.startsWith(ROUTER_PREFIX)) {
v1.path = v1.path.slice(ROUTER_PREFIX.length) || '/'
}

return trpcHandler(event, context)
}
16 changes: 9 additions & 7 deletions packages/trpc-api/src/trpcUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import express from 'express'
const { appRouter } = await import('../../../infra/src/apiHandler')

app.use('/panel', (_, res) => {
const trpcApiResource = process.env.SST_RESOURCE_tRPCAPI
if (!trpcApiResource) {
throw new Error('SST_RESOURCE_tRPCAPI environment variable is missing')
const routerResource = process.env.SST_RESOURCE_Main
if (!routerResource) {
throw new Error('SST_RESOURCE_Main environment variable is missing')
}

const parsedResource = JSON.parse(trpcApiResource)
const url = parsedResource.url
if (!url) {
throw new Error('URL not found in SST_RESOURCE_tRPCAPI resource')
const parsedResource = JSON.parse(routerResource)
const routerUrl = parsedResource.url
if (!routerUrl) {
throw new Error('URL not found in SST_RESOURCE_Main resource')
}

const url = `${routerUrl}/api`

return res.send(
renderTrpcPanel(appRouter, {
url,
Expand Down
4 changes: 4 additions & 0 deletions sst-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ declare module "sst" {
"name": string
"type": "sst.aws.Function"
}
"Main": {
"type": "sst.aws.Router"
"url": string
}
"Web": {
"type": "sst.aws.StaticSite"
"url": string
Expand Down
19 changes: 14 additions & 5 deletions sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,29 @@ export default $config({
args.runtime = 'nodejs22.x'
})

const router = new sst.aws.Router('Main', {
// "oac-with-edge-signing" (not plain "oac") so POST requests work.
// "oac" alone requires every client to send x-amz-content-sha256 on
// each POST; edge-signing adds a Lambda@Edge that handles it for us.
protection: 'oac-with-edge-signing'
})

// stacks
await import('./domains/transaction/features/deposit/stack')
const api = await import('./infra/api')
const { createApi } = await import('./infra/api')
const tRPCAPI = createApi(router)

// dev commands visible when running sst dev
new sst.x.DevCommand('tRPC', {
link: [api.tRPCAPI],
link: [tRPCAPI, router],
dev: {
autostart: true,
command: 'pnpm --filter @purple-stack/trpc-api start:panel'
}
})

const web = new sst.aws.StaticSite('Web', {
new sst.aws.StaticSite('Web', {
router: { instance: router },
build: {
command: 'pnpm run --filter @purple-stack/web build',
output: 'web/dist'
Expand All @@ -46,12 +55,12 @@ export default $config({
directory: 'web'
},
environment: {
VITE_tRPCAPI_url: api.tRPCAPI.url
VITE_tRPCAPI_url: $interpolate`${router.url}/api`
}
})

return {
Web: web.url
Url: router.url
}
}
})
Loading