AppTheory v1.0 Security Migration Guide
This guide consolidates the security-hardening changes that intentionally move AppTheory to the v1.0 fail-closed baseline.
Read this guide before promoting a pre-v1 deployment that relied on previous permissive behavior in AppTheory’s runtime, MCP transport, or CDK constructs. Not every hardening item needs a migration action, but the ones below are the surfaces most likely to affect operator configuration, dashboards, tests, or integration expectations.
Auth hooks now reject empty principal identities
Affected surface:
- Go runtime
WithAuthPrincipalHook(...)+ protected routes usingRequireAuth()
What changed:
- Returning a non-nil principal with an empty
Identityno longer satisfiesRequireAuth(). - Empty or whitespace-only identities are now treated as unauthenticated and return
401.
What you need to do:
- Ensure your principal hook returns:
nil/ unauthenticated when identity cannot be established, or- a principal with a non-empty
Identitywhen authentication succeeds.
- If you previously used an empty identity as a sentinel value, replace that with explicit hook-local state instead.
Why this changed:
- Empty identities were a fail-open ambiguity in the protected-route contract.
Remote MCP bearer protection now fails closed
Affected surface:
runtime/oauth.RequireBearerTokenMiddleware(...)runtime/mcp.ServerinitialGET /mcplistener behaviorruntime/mcpsession lifecycle on durable stores
What changed:
- You must provide a
Validator. If you omit it, the middleware now rejects every request with401instead of accepting any syntactically validAuthorization: Bearer ...token. - The
WWW-Authenticateresource_metadatachallenge is derived only from an explicitResourceMetadataURLor fromMCP_ENDPOINT. It is no longer derived fromHost/X-Forwarded-Protorequest headers. GET /mcpwithoutLast-Event-IDnow emits a short-lived keepalive SSE response and closes by default instead of staying open indefinitely.- Expired MCP sessions now fail closed instead of being refreshed back to life when a durable session record still exists.
- Panics in streaming tool execution are recovered into internal-error output instead of terminating the Go process.
What you need to do:
- Provide a real token validator (JWT verification, introspection, or equivalent) whenever you use
RequireBearerTokenMiddleware(...). - Ensure the middleware has an explicit metadata source:
- set
ResourceMetadataURL, or - deploy through
AppTheoryRemoteMcpServersoMCP_ENDPOINTis injected.
- set
- If you previously depended on request-header-derived metadata discovery, replace that with explicit configuration.
- If your client depended on an indefinitely open initial
GET /mcplistener with noLast-Event-ID, either:- move to resumable stream replay, or
- opt in explicitly with
mcp.WithInitialSessionListenerBudget(...)for a bounded open-listener window.
- Do not rely on expired session IDs remaining usable until DynamoDB TTL cleanup eventually runs; expired sessions now
return
404.
Why this changed:
- Accepting arbitrary Bearer tokens when no validator was configured was not fail-closed.
- Deriving protected-resource metadata from request headers trusted attacker-influenced inputs in proxy setups.
- Indefinitely open initial listeners and session resurrection both undermined the intended MCP transport and session boundaries.
HTTP source IP now uses provider provenance
Affected surface:
- Go / TypeScript / Python HTTP handlers and middleware that need the provider-observed source IP
- Local tests that build API Gateway v2 or Lambda Function URL events
- Product migration shims, including Lesser’s temporary bridge-header injection
What changed:
- HTTP requests now carry a portable
SourceProvenancevalue and a convenienceSourceIP/sourceIP/source_ipaccessor in every HTTP tier, including P0. - The value is derived only from AWS provider request context:
- API Gateway v2 HTTP API:
requestContext.http.sourceIp - Lambda Function URL:
requestContext.http.sourceIp - API Gateway v1 REST proxy:
requestContext.identity.sourceIp
- API Gateway v2 HTTP API:
- Valid IPs are canonicalized across runtimes before exposure. For example,
2001:DB8::1becomes2001:db8::1. - Missing or malformed provider values return unknown/invalid provenance instead of failing the request or broadening to forwarding headers.
- ALB does not have source provenance in this pass; it returns unknown/invalid provenance.
What you need to do:
- Replace any temporary bridge-header reads with the AppTheory context accessor:
- Go:
ctx.SourceIP()orctx.SourceProvenance() - TypeScript:
ctx.sourceIP()orctx.sourceProvenance() - Python:
ctx.source_ip()orctx.source_provenance()
- Go:
- Update deterministic tests to set provider source metadata through the test builders:
- Go:
testkit.HTTPEventOptions{SourceIP: "..."} - TypeScript:
buildAPIGatewayV2Request(..., { sourceIp: "..." }) - Python:
build_apigw_v2_request(..., source_ip="...")
- Go:
- Remove Lesser’s bridge-header injection after the application is upgraded to the source provenance API.
- Do not replace the bridge header with
ForwardedorX-Forwarded-Forparsing inside AppTheory handlers. Those headers are viewer-controlled unless a product owns a separate trusted-proxy chain outside the AppTheory contract.
Why this changed:
- Public source-IP strings are a cross-language contract, so the runtime must expose the same canonical value in Go, TypeScript, and Python.
- Header-derived source IPs are not fail-closed without a trusted-proxy configuration. AppTheory’s portable contract therefore uses provider context only.
Credentialed CORS now requires an explicit allowlist
Affected surface:
- portable CORS config in Go / TypeScript / Python when
allow_credentials/allowCredentialsis enabled
What changed:
- Non-credentialed CORS still treats an omitted allowlist as allow-all.
- Credentialed CORS now fails closed unless you configure explicit
allowed_origins. - When credentials are enabled and no allowlist is configured, AppTheory no longer reflects the request origin or emits
Access-Control-Allow-Credentials.
What you need to do:
- Whenever you enable credentialed CORS, set an explicit allowlist for the exact origins that should receive browser credentials.
- Update tests that previously expected origin reflection with
AllowCredentialsbut no allowlist.
Why this changed:
- Credentialed origin reflection without an allowlist is a browser-facing fail-open footgun.
Streamed responses now honor max_response_bytes
Affected surface:
- Go / TypeScript / Python response streaming when
max_response_bytes/maxResponseBytesis configured
What changed:
- Streamed responses are now subject to the same response-size guardrail as buffered responses.
- Once a stream would exceed the configured limit, the already-committed status and headers stay intact and the stream
terminates with
app.too_large.
What you need to do:
- Review any streaming endpoints that relied on
max_response_bytesbeing ignored for streamed bodies. - Increase the configured limit, split the stream into smaller responses, or remove the limit for that handler if the larger output is intentional.
Why this changed:
- Streaming previously bypassed the configured response-size guardrail entirely.
AppSync unexpected exceptions now mask to internal error
Affected surface:
- Go / TypeScript / Python AppSync resolver adapters when handlers throw non-portable exceptions
What changed:
- Non-portable AppSync exceptions no longer echo raw exception text to clients.
- AppTheory/AppError values still preserve their intended portable messages and metadata.
What you need to do:
- If you want a client-visible AppSync message, throw a portable AppTheory/AppError instead of a generic exception.
- Update tests that previously matched raw exception strings in AppSync responses.
Why this changed:
- Leaking raw exception text from AppSync created an avoidable information-disclosure path.
Timeout middleware is cooperative cancellation, not hard preemption
Affected surface:
- Go / TypeScript / Python timeout middleware
What changed:
- Timeout middleware now documents and tests a cooperative cancellation contract across runtimes.
- A timed-out request returns
app.timeout, but user code must observe cancellation (ctx.Done(),AbortSignal, or equivalent cooperative checks) if it needs to stop work before side effects commit.
What you need to do:
- Update long-running handlers to observe cancellation rather than assuming the middleware can forcibly stop execution.
- Treat timeout middleware as a response contract plus cancellation signal, not as a hard kill switch.
Why this changed:
- Force-killing goroutines, promises, or threads is not portable across the three runtimes; the contract needed to make the cooperative model explicit and deterministic.
Go rate-limit middleware now fingerprints credential-derived identifiers by default
Affected surface:
runtime.RateLimitMiddleware(...)when you rely on the defaultExtractIdentifier
What changed:
- The Go runtime no longer stores raw credential material as the default limiter identifier.
- Requests identified by
x-api-keynow useapi_key:hmac-sha256:<hex>. - Requests identified by
Authorization: Bearer ...now usebearer:hmac-sha256:<hex>. AuthIdentity,TenantID, and explicitExtractIdentifieroverrides are unchanged.
What you need to do:
- Expect a one-time bucket reset for any deployment that previously keyed limits directly on API keys or Bearer tokens.
- Update dashboards, operational tooling, or table inspection workflows that expected raw credential values in limiter keys.
- If you need a different identifier shape, provide an explicit
ExtractIdentifierinstead of depending on the default.
Why this changed:
- Raw API keys and Bearer tokens should not be stored in rate-limit tables by default.
- HMAC fingerprinting keeps default limiter behavior deterministic while reducing credential exposure in storage and diagnostics.
Sanitization once again redacts token-like keys by default
Affected surface:
- Go / TypeScript / Python sanitization helpers
What changed:
- Segment-based secret redaction heuristics were restored for token-like field names.
authorization_idis no longer treated as an allowlisted clear-text identifier; it now redacts as a secret alias.- Business keys that merely contain those substrings as part of a larger identifier (for example
authorizationCodeortokenization_method) remain readable.
What you need to do:
- Expect some log fields to become redacted again, including
authorization_id. - Update dashboards or support tooling that depended on reading those values directly from logs.
Why this changed:
- The previous allowlist/substring behavior let token-like fields fall through to clear-text logging too easily.
AppTheorySsrSite now fails closed on Function URL access and tenant-header trust
Affected surface:
cdk/lib/AppTheorySsrSite
What changed:
- Omitted
ssrUrlAuthTypenow defaults toAWS_IAMfor all SSR site topologies. CloudFront signs the Lambda Function URL origin with lambda Origin Access Control by default. - Public direct Function URL access now requires an explicit compatibility opt-in:
ssrUrlAuthType: lambda.FunctionUrlAuthType.NONE. - Viewer-supplied tenant headers are no longer trusted by default:
x-tenant-idis stripped at the edge- tenant-like entries in
ssrForwardHeadersare rejected unless you explicitly setallowViewerTenantHeaders: true
- The default
ssg-isrHTML cache key no longer varies on tenant-like viewer headers unless compatibility passthrough is explicitly enabled.
What you need to do:
- If you depended on public direct Function URL access, set
ssrUrlAuthType: lambda.FunctionUrlAuthType.NONEexplicitly. - If you previously forwarded
x-tenant-idor headers such asx-facetheory-tenantfrom the viewer, migrate to a trusted derivation model:- derive tenant from
x-apptheory-original-host/x-facetheory-original-hostinside the SSR function using your allowlisted host mapping, or - inject a trusted tenant header upstream before the request reaches the AppTheory origin contract.
- derive tenant from
-
If you need temporary backwards compatibility while you migrate, set:
allowViewerTenantHeaders: true, ssrForwardHeaders: ["x-facetheory-tenant"],This restores legacy passthrough, but those tenant headers remain viewer-controlled.
Why this changed:
- Public Function URL defaults bypass CloudFront-only controls such as WAF, geo/IP restrictions, and signed-origin enforcement.
- Forwarding viewer-supplied tenant headers without a trust contract let clients influence origin tenant context and
ssg-isrHTML cache partitioning.
WAF ipWhitelist now behaves like a real allowlist
Affected surface:
AppTheoryEnhancedSecuritywithwafConfig.ipWhitelist
What changed:
- Configuring
ipWhitelistnow synthesizes default-deny WebACL behavior instead of an allow rule layered on top of a default-allow ACL.
What you need to do:
- Re-check any smoke tests or operational expectations that assumed non-whitelisted traffic would still pass through.
- If you were relying on the previous ineffective behavior, replace it with an intentional blacklist or no whitelist at all.
Why this changed:
- An allowlist that does not deny non-matching traffic is not an allowlist.
Jobs and EventBus hardening now reject permissive edge cases earlier
Affected surface:
- Go / TypeScript / Python jobs semaphore acquisition
- Go EventBus publish/query behavior
- Go batch event middleware context usage
What changed:
- Semaphore acquisition now rejects pathological
limitvalues above256before entering the per-slot storage loop. - EventBus publish always derives persisted partition/sort keys from validated event data instead of honoring caller-supplied storage keys.
- Batch event handlers receive isolated per-record
EventContextvalues soctx.Set(...)state does not leak across records.
What you need to do:
- Clamp any user-facing semaphore limit knobs to
256or below. - Do not rely on caller-supplied EventBus storage keys; only
TenantID,EventType,PublishedAt, andIDdefine persisted keys now. - If you intentionally shared per-record batch state through
EventContext, move that state into your own batch-level coordinator instead ofctx.Set(...).
Why this changed:
- These edge cases created avoidable write amplification, tenant-boundary confusion, or cross-record state leakage.
Internal-only hardening with no operator migration action
The v1.0 foundation also included low-level fixes that should not require configuration changes:
- prototype-safe TypeScript header canonicalization for
constructor/__proto__ - earlier oversized base64 request rejection before full decode allocation
- explicit preservation of legacy
queuePropssecurity settings inAppTheoryQueueProcessor
You should not need to change configuration for these items, but they are part of the same fail-closed baseline reset.