Architecture Decision Record
ADR-131: Same-Origin API Proxy (a Backend-for-Frontend That Keeps Tokens Out of Browser Script)
Status
Accepted (2026-10-01). Targeted at MMCA.Common v1.218.0, which is unreleased at the time of writing.
Records the owner's TD-08 decision for Option A of the token-storage design note; the implementing
MMCA.Common commits are 4cfb4a35 (the proxy), b542dde1 (downloads through the proxy) and fba02c29
(refresh outcomes and the same-origin gate). Opt-in per host: a host that does not call
AddCommonSameOriginApiProxy is unchanged (MMCA.Common/UPGRADING.md:149). Extends
ADR-022 (the HttpOnly session cookie, until now read only for
server-side rendering) and ADR-051 (the client token
lifecycle) without changing ADR-088 (the gateway stays the
API edge).
Context
A Blazor WebAssembly client calls the API through the gateway, which is a different origin from the
UI host, so the HttpOnly session cookies the UI host writes (ADR-022) never travel with those calls.
The client therefore authenticates with a bearer token script can read: a host that does not opt in
still attaches the stored token to the notification hub through
options.AccessTokenProvider = _tokenStorageService.GetAccessTokenAsync
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:366),
and to every "APIClient" call through AuthDelegatingHandler
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:12). One
successful script injection can then lift a live token and use it from anywhere until it expires.
The ADC token-storage design note (kept in the private ADC repository) weighed four answers to "how
does an HttpOnly credential reach a cross-origin API": A, a same-origin proxy on the UI host; B, a
cookie scoped to a registrable domain shared by UI and API, sent straight to the gateway; C, keep the
script-readable token and shrink the blast radius (enforced CSP, short TTLs); and C+, an interim
hybrid with the refresh token in the HttpOnly cookie and the access token held in memory. C+ removes
the long-lived prize but still leaves a usable access token in script for its lifetime. The pieces A
needs already existed: the cookie pair and its writer (ISessionCookieStore,
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/ISessionCookieStore.cs:14, HttpOnly,
Secure outside Development, 7-day lifetime, :9-10), and a single-flight refresher over that cookie
(ICookieSessionRefresher,
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:32, striped
per refresh token, :63-72).
Decision
The UI host serves the API on its own origin and forwards to the gateway server-side, attaching the bearer it reads from the HttpOnly cookie. The browser never holds a token that any API accepts.
- Opt-in, hosted in MMCA.Common.UI.Web.
AddCommonSameOriginApiProxy(IConfiguration)(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:51) binds and validates the settings on start (:55-67), registers YARP's forwarder (:76) and data protection (:77), and replaces the Blazor Server circuit'sITokenRefresherandISessionCookieSyncwith protected-handoff implementations (:83-84).MapCommonSameOriginApiProxy()(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpointExtensions.cs:33) maps{PathPrefix}/{**path}for every method, WebSocket upgrades included, anonymous by declaration, without antiforgery and outside the OpenAPI description (:49-52). It fails the boot when the registration call is missing (:38-42) or when a later registration displaced the handoff services, because that would put the access token back in the page (:59-69,:85-90). - Settings.
SameOriginApiProxySettings(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettings.cs:21, sectionSameOriginApiProxy,:24):PathPrefix/api(:38),GatewayAddressdefaulting toApi:ApiEndpointso an Aspire discovery name resolves as it does for the host's other clients (:46, filled atSameOriginApiProxyServiceExtensions.cs:57-64), token-issuing pathsauth/login,auth/register,auth/oauth/exchangeplus anyAdditionalTokenIssuingPaths(:31,:52),RefreshPathauth/refresh(:59),RevokePathauth/revoke(:66) andSessionCookieSameSiteStrict(:74). The validator refuses a prefix with route syntax, a non-absolute gateway and anySameSiteother thanStrictorLax(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettingsValidator.cs:26-43). - The request pipeline, in order.
SameOriginApiProxyEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:26) runs every gate before anything is forwarded (:209-235):- Same-origin gate. An
Originthat is not exactly the host's own (scheme, host and port as the app sees them; a sibling subdomain is another origin), a WebSocket upgrade with noOrigin, or aSec-Fetch-Siteother thansame-originis refused 403cross_origin_rejected;noneis allowed only on a plain GET or HEAD, for a user-initiated navigation such as a pasted download link (:109-185, own-origin comparison:139-153, refusal:211-216). OPTIONSanswered locally with 204 and no CORS grant, never forwarded, so the gateway's CORS policy is never consulted on the proxy's behalf (:218-225).- CSRF header. Every unsafe method must carry exactly
X-CSRF: 1or gets 403csrf_header_required(:101-107,:227-232); the constants areSameOriginProxyHeaders(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyHeaders.cs:11,:14,:17). - Session step. A request with a session cookie is validated or refreshed through
ICookieSessionRefresher.ValidateOrRefreshAsync; sign-in requests skip it so a stale session cannot block a login (SameOriginApiProxyEndpoint.cs:79-90). - Forward through YARP over HTTP/1.1 with a 100-second activity timeout, so WebSocket upgrades
forward as plain upgrades (
:34-42,:315-325), with the bearer attached server-side. - One forced refresh and replay when a safe, bodiless method comes back 401 (a revoked token
or a rotated key); nothing with a body is re-sent (
:191-194,:289-305).
- Same-origin gate. An
- What the forward rewrites.
SameOriginProxyTransformerreplaces any browserAuthorizationwith the session's bearer, drops theX-CSRFheader and strips the two session cookies from the upstreamCookieheader (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:60-62,:156-172). A successful token-issuing response moves the pair into the cookies and reaches the browser withaccessTokenreplaced by its claims-only form andrefreshTokenemptied (:91,:134-143); a revoke forwards the bearer and clears the cookies whatever the upstream answered (:84-87). A browser POST to the refresh path is answered by the proxy itself from the refresh cookie, in the same stripped shape (SameOriginApiProxyEndpoint.cs:267-287,:332-350). - Stateless: tokens stay in the existing cookie. No server-side session store; the proxy reads and
writes the same HttpOnly pair through
ISessionCookieStore(ISessionCookieStore.cs:20,:24). - The browser holds an unsigned claims copy.
SessionClaimsToken.Create(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionClaimsToken.cs:25-33) keeps the access token's payload under an{"alg":"none","typ":"JWT"}header with an empty signature (:17), enough to render authentication state and nothing any API accepts (:7-13). Opting in setsSessionCookieSettings.ClaimsOnlyBrowserTokens, so/auth/session/tokenreturns the claims copy andPOST /auth/session-cookieignores script-supplied tokens (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieSettings.cs:21-29, set atSameOriginApiProxyServiceExtensions.cs:69-74). - Refresh outcomes are three, not two.
SessionRefreshStatus(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionRefreshOutcome.cs:4) isRefreshed,Rejected(no refresh cookie, or the identity endpoint answered 400, 401 or 403) andUnavailable(5xx, 429, timeout, network failure, unreadable body) (:10-23), classified atCookieSessionRefresher.cs:119-121. The proxy clears the cookies and answers 401 only onRejected; onUnavailableit keeps them, forwards and replays nothing, and answers 503 with the upstreamRetry-After, or 5 seconds when there is none (SameOriginApiProxyEndpoint.cs:45,:237-261). - The Blazor Server circuit trades handoffs, not tokens. The circuit keeps calling the gateway
server-to-server; it exchanges tokens with the cookies only as data-protected, purpose-bound
handoffs that live one minute
(
MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffProtector.cs:19,:21-27). - The WebAssembly client switches by configuration.
/client-configaddsSameOriginApiEndpointonly on an opted-in host (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:99), the bootstrap resolves it against the app base address (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/MmcaClientConfigBootstrap.cs:98), andApiSettings.SameOriginApiEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/ApiSettings.cs:33) then becomes the"APIClient"base address (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:114) and addsSameOriginProxyRequestHandler(DependencyInjection.cs:131-132), which removes anyAuthorizationheader and stampsX-CSRF: 1(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyRequestHandler.cs:11,:18-20). - Hubs are proxied too. On an opted-in client the notification hub connects through the proxy
with the CSRF header and no access-token provider (
NotificationHubService.cs:76,:360-364); the proxy attaches the bearer to the upgrade server-side. - Downloads follow the API client.
ApiFileDownloadButtonresolves its anchor base asSameOriginApiEndpoint, thenWasmApiEndpoint, thenApiEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Forms/ApiFileDownloadButton.razor.cs:98), so a top-level GET carries the cookie and the proxy supplies the bearer. - MAUI is excluded. Native heads keep OS SecureStorage and talk to the gateway directly; the
"APIClient"pipeline changes only whenSameOriginApiEndpointis configured, which only an opted-in host's WebAssembly client receives (DependencyInjection.cs:95-99). - SameSite: Strict by default, Lax allowed. The framework-wide cookie default stays
Lax(SessionCookieSettings.cs:19); opting in raises it to the proxy'sSessionCookieSameSite,Strictunless the host choosesLax(SameOriginApiProxySettings.cs:68-74). CSRF protection rests on the header and origin gates above, not onSameSite. ADC's 1.218.0 adoption branch (feat/common-1.218.0-adoption, unmerged at the time of writing) setsLaxso a signed-in user arriving from a mailed deep link keeps the session on the first server render (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:47-51). - Gates. The behavior is pinned by
SameOriginApiProxyOptInTests(hosts that do not opt in are unchanged,MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/SameOriginProxy/SameOriginApiProxyOptInTests.cs:21),SameOriginApiProxyCsrfTests(SameOriginApiProxyCsrfTests.cs:12),SameOriginApiProxyOriginTests(SameOriginApiProxyOriginTests.cs:14),SameOriginApiProxyRefreshOutcomeTests(SameOriginApiProxyRefreshOutcomeTests.cs:14),SameOriginApiProxyHubTests(SameOriginApiProxyHubTests.cs:25),SameOriginApiProxyTokenTests(SameOriginApiProxyTokenTests.cs:15) andSameOriginApiProxyAuthFlowTests(SameOriginApiProxyAuthFlowTests.cs:20), all in the same folder.
Rationale
- Tokens out of script, with no infrastructure prerequisite. An injected script can still call the
API as the user while the page is open, but it cannot carry a token away: the only token in script
is the unsigned claims copy (
SessionClaimsToken.cs:7-13). Option B buys the same property only once UI and API share a registrable domain in every environment. - Reuse, not a second auth system. The proxy is built on the cookie writer and the single-flight
refresher that already served server-side rendering (
ISessionCookieStore.cs:14,CookieSessionRefresher.cs:63-72), so there is one place a session is refreshed and one cookie format. - The right package. MMCA.Common.UI.Web is server-only; the MMCA.Common.UI Razor class library is loaded by WebAssembly and MAUI heads, which cannot host a forwarder, and MMCA.Common.API is referenced by every service, which would carry a YARP dependency into hosts that never front a browser.
- Defense in depth for CSRF. A cookie that authenticates data calls must not be usable from
another origin. The custom header cannot be sent cross-origin without a preflight, and the proxy
never answers a preflight with a grant (
SameOriginApiProxyEndpoint.cs:218-225); the origin gate covers what the header cannot, a WebSocket upgrade, which is a GET and is not CORS-protected (:116-117). Both gates were added after an adversarial review found upgrades and preflights open (commit fba02c29). - A blip at the identity endpoint must not sign users out. Treating every failed refresh as the
end of the session cleared the cookies on a 5xx or a timeout; separating
UnavailablefromRejectedkeeps a live session and tells the client when to retry (SessionRefreshOutcome.cs:12-23). - SameSite is a usability choice, not the CSRF control. Because the header and origin gates carry
CSRF, a consumer can choose
Laxfor deep links without weakening the data path;Nonestays impossible (SameOriginApiProxySettingsValidator.cs:40-43).
Trade-offs
- One extra hop. Every browser API call and every hub frame passes through the UI host before the
gateway (
SameOriginApiProxyEndpoint.cs:315-319). - The UI host becomes a stateful-ish auth edge. It owns refresh, the refresh race, the 401 replay
and cookie rotation for browser traffic (
SameOriginApiProxyEndpoint.cs:289-305,:332-350); a UI host outage now takes the browser's API path down with it. - The UI host rate limiter now meters API traffic. Its exempt prefixes are
/health,/alive,/_framework,/_contentand/hubs(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/UiRateLimitingExtensions.cs:50), so every proxied/api/**call, including the hub at/api/hubs/notifications(NotificationHubService.cs:76), counts against the per-IP window and the concurrency ceiling of ADR-124. - Ingress hosts must adopt forwarded headers or lose every POST. The origin gate compares against
the scheme, host and port as the app sees them (
SameOriginApiProxyEndpoint.cs:132-138), so a host behind a TLS-terminating ingress must callUseCommonUiForwardedHeaders()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeadersExtensions.cs:24) first, or every browser POST is refused (UPGRADING.md:139-142). - Registration order matters.
AddCommonSameOriginApiProxymust come after everyITokenRefresherandISessionCookieSyncregistration; the boot fails otherwise (SameOriginApiProxyServiceExtensions.cs:29-35). - Client code that presented the browser-held token breaks. Decoding it for claims still works;
sending it to the gateway does not (
UPGRADING.md:143-144), and requests a host writes by hand must addX-CSRF: 1on unsafe methods (UPGRADING.md:137-138). - Script on the page can still act as the user. The proxy removes theft, not use: an injected script runs same-origin and passes both gates. Content Security Policy (ADR-023) stays the control for that.
Alternatives rejected
- Option B: a same-site cookie sent straight to the API (weighed 2026-10-01). Rejected because it needs UI and gateway under one registrable domain in every environment (DNS, certificates, infrastructure), a cookie-JWT path in every service, and credentialed gateway CORS. Revisit if production puts UI and API under one parent domain for other reasons.
- Stopping at the C+ hybrid (weighed 2026-10-01). Rejected because the access token stays in script for its lifetime, so theft is shortened rather than removed. C+ was the interim step, not the target.
- A server-side session store (rejected 2026-10-01). Holding tokens in a store keyed by an opaque session id would make every UI host depend on a stateful store and its availability; the existing HttpOnly cookie already keeps the tokens out of script, so the proxy stays stateless. Revisit if token size outgrows a cookie or server-side revocation of a browser session becomes a requirement.
- Hosting the proxy in MMCA.Common.UI or MMCA.Common.API (rejected 2026-10-01). The Razor class library is loaded by WebAssembly and MAUI heads, and the API package by every service, which would push YARP into hosts that front no browser.
SameSiteor an antiforgery token as the CSRF control (rejected 2026-10-01).SameSite=Laxmust remain available for deep links, and the API client is not a form post; the fixed header plus the origin gate cover both fetches and WebSocket upgrades.- Forwarding
OPTIONSto the gateway (rejected 2026-10-01, after review). The gateway's CORS policy would then decide whetherX-CSRFmay be sent cross-origin to the proxy (SameOriginApiProxyEndpoint.cs:218-219). - Proxying MAUI too (rejected 2026-10-01). A native head has no DOM and no script-injection surface, keeps tokens in OS SecureStorage, and would only gain a hop.
Consequences
- Adoption, per Blazor Web host. Register after the session-cookie and token registrations, map
next to the session-cookie endpoints after
UseAuthorization, configure the section only where a default does not fit, and callUseCommonUiForwardedHeaders()first behind an ingress (UPGRADING.md:118-149). - The gateway URL stays configured.
ApiEndpointkeeps the gateway address for full-page navigations that must reach the gateway itself, such as the external sign-in challenge (ApiSettings.cs:25-31). - What to watch. UI host 429s and concurrency rejections on
/api/**after a consumer opts in (the limiter above now sees API and hub traffic), 503session_refresh_unavailablerates as a signal of identity-endpoint health, and 403cross_origin_rejectedspikes after an ingress change, which usually mean forwarded headers are missing.
Related
ADR-022 (the HttpOnly session cookie the proxy reads),
ADR-051 (client token lifecycle across render modes),
ADR-023 (security headers and the CSP),
ADR-082 (the gateway CORS posture the proxy never consults),
ADR-088 and
ADR-089 (the gateway behind the proxy),
ADR-124 (the UI host as its own hardened edge),
ADR-070 (startup validation of the proxy settings).
Framework version and package figures live in MMCA.Common/FACTS.md.