Same-origin, the login problem, and how to solve it
Why the session drops on refresh (F5) in QA (issue #553), what each term means, what breaks if you get it wrong, and the options to do it right โ for the QIIUB admin portal, merchant portal, and shared API.
The identity of a web address = scheme + host + port. https://admin.qa.qiiub.com and https://api.qa.qiiub.com are different origins (different host). The browser walls off each origin from the others for security.
Same-origin
The web page and the API it talks to live under the one origin. Cookies the API sets are then readable by the page's JavaScript, and no CORS is needed.
CORS โ Cross-Origin Resource Sharing
The browser's controlled exception to the origin wall: a page on origin A may call origin B only if B sends headers explicitly allowing it. It's what you need when the SPA and API are on different origins. Same-origin removes the need for it entirely.
Cookie / HttpOnly / SameSite
A small credential the browser stores and re-sends. HttpOnly = JavaScript can't read it (protects the refresh token). SameSite=Strict = only sent on same-site navigations. A cookie is bound to the host that set it โ JS on another host can't read it.
CSP โ Content-Security-Policy
An HTTP response header that tells the browser which sources a page may load scripts/styles/connections/frames from. It's the main defense against XSS (an attacker injecting a script). QIIUB emits an enumerated allowlist for the SPA hosts (P4).
HSTS โ HTTP Strict-Transport-Security
A response header that tells the browser "always use HTTPS for this site, never plain HTTP." Prevents downgrade / man-in-the-middle. QIIUB emits it outside Development (P3).
Reverse proxy
A server that sits in front and forwards requests to a backend, so the browser only ever sees one address. Nginx, YARP, CloudFront, Cloudflare, and Azure Front Door can all act as one.
BFF โ Backend-for-Frontend
The pattern of giving each frontend a same-origin backend that holds the tokens/cookies and proxies to the real services. The recognized secure pattern for SPA auth. Coined at SoundCloud (2015); used by Netflix, Spotify; documented by Microsoft, Auth0, Duende.
Azure Front Door
Microsoft's global edge service: reverse proxy + CDN (caches content near users) + Web Application Firewall (WAF) + DDoS protection + global routing/TLS. Primary uses: put a fast, protected front on your app worldwide. It can also serve as the same-origin reverse proxy. Paid, recurring.
WAF / DDoS
A WAF (Web Application Firewall) filters malicious requests (injection, bots). DDoS = distributed denial-of-service, a flood of traffic meant to knock you offline; mitigated at the edge (Cloudflare / Front Door), not in the app.
CDN
Content Delivery Network โ caches your static files on servers near users for speed. Azure Static Web Apps and Cloudflare/Front Door are CDNs.
The problem
The browser treats each origin as a sealed room. A cookie the API drops lives in the API's room; the portal's JavaScript, running in a different room, cannot read it.
QA today drops on F5
Two hosts = two origins. The API drops the cookie in its room; the portal can't read it โ refresh is sent with no credential โ 401 โ logout.
Same-origin F5 works
One host serves the portal and/api. The cookie is in the same room โ the portal reads it โ refresh succeeds โ session survives.
Key point: the login logic already lives in the shared API (both portals call the same /api/auth/*). Same-origin does not move the login โ it only makes the browser load each portal from the same origin as its API, so the cookie is readable.
What breaks if you get it wrong
Cross-origin (today): user logs in, but any refresh โ F5, a new tab, or the silent 13-min refresh โ fails because the CSRF cookie is unreadable โ user is bounced to /login. Looks like "it keeps logging me out." (This is #553.)
Cookie without Secure/wrong SameSite: either the browser refuses to store it, or it leaks across sites. Auth silently breaks or becomes insecure.
Prefix rewrite in the proxy: if a proxy rewrites /api to something else, the refresh cookie's Path=/api/auth no longer matches โ cookie never sent โ logout. (Why the /api path must be forwarded verbatim.)
No CSP: an injected script can run and steal data. No HSTS: a downgrade/MITM can intercept. Both are one-line headers we now emit.
CORS left wide open (*) as a lazy "fix" for the cross-origin pain: turns the API into an open target. The right fix is same-origin, not permissive CORS.
The options to do it right
All of these achieve same-origin. They differ in cost, deploy independence, and extra moving parts. (A/B/C are the mechanisms evaluated in the QA spike; the edge-proxy row and Front Door are the broader alternatives.)
Option
How it works
Cost
Deploys
Verdict
A โ SWA Linked Backend
The Static Web App proxies /api to the App Service on the SPA's own origin.
Needs SWA Standard (~$9/app/mo ร 2) โ recurring.
staticwebapp.config.json rewrite to an external API URL.
โ
โ
Not supported โ SWA rewrites can't target an external host.
C โ Serve SPAs from the App Servicechosen / merged
The API App Service also serves the built SPAs from wwwroot/{admin,portal}; /api is native (no proxy).
$0 โ reuses the App Service, retires the 2 SWAs.
Coupled (SPA + API ship together)
Meets every constraint; official ASP.NET Core pattern.
Edge โ Azure Front Doorprod decision
Front Door sits in front; the edge routes /api to the API (verbatim) and serves the SPA from its CDN. Adds CDN + WAF + DDoS. SPA can stay on its own host.
~$35/mo base (Standard) + usage; recurring.
Independent
Mike's strategic edge/WAF decision (2026-05-22) โ see "Decision & environments".
The trade-off in one line: Plan C = simplest and free, but SPA and API ship together and there's no CDN/WAF/DDoS. Front Door edge = independent deploys + CDN + WAF + DDoS, at a recurring base fee. (Cloudflare would do the same technically, but was rejected โ see the decision note below: the org chose an all-Azure pane.)
Decision & environments (current thinking)
Two decisions at two scopes โ they coexist:
Strategic edge (Mike, 2026-05-22): the platform edge/WAF is Azure Front Door + Azure DNS โ an all-Azure pane for the PCI / enclave / multi-region future. This is the prod answer.
QA (now):no Front Door. QA is lean/disposable โ the same-origin login is solved for $0 by serving the SPAs from the App Service (Plan C, already merged). Local dev is same-origin via the Vite proxy. Front Door would bill ~$35/mo even while parked, with nothing in QA that needs it.
Environment
Same-origin via
Edge (CDN/WAF/DDoS)
Cost
Local dev
Vite dev proxy
โ
$0
QA
Plan C (App Service serves SPAs)
none (not needed)
$0
Prod
Azure Front Door routes /api
Front Door (Mike's decision)
~$35/mo+
Accepted trade-off: QA and prod end up on different topologies (Plan C vs Front Door), which breaks strict dev/prod parity. Accepted for now to keep QA cheap; revisit if the parity cost outweighs the savings.
The code doesn't lock the topology.SpaHosting (Plan C) is inert unlesswwwroot/{admin,portal} exists, and the SPAs build with VITE_API_URL='' (same-origin relative /api) โ which works whether same-origin comes from Plan C or from a Front Door edge. So the same API binary + same SPA build deploy either way; only the pipeline differs per environment (QA bundles the SPAs into the API; prod ships them separately and puts Front Door in front). Prod's Front Door must forward /apiverbatim and pass cookies (Path=/api/auth) โ a config concern, not code.
Is this industry-standard? Yes.
BFF pattern โ coined at SoundCloud (2015); used by Netflix, Spotify; a documented Azure Architecture Center pattern; the Auth0/Duende/FusionAuth recommendation for SPA auth with HttpOnly cookies.
Same-origin via server/proxy โ recommended by AWS (CloudFront same-domain) and Microsoft (serve SPA from ASP.NET Core; Front Door as SPA proxy) to eliminate CORS and keep cookies first-party.
Serving the SPA from the API app (Plan C) is Microsoft's official ASP.NET Core production pattern (static files + MapFallbackToFile).
Salesforce uses My Domain (custom domains) so the UI and session share a host โ same principle.
Why our existing MVC apps (WIDS365) don't hit this
It's not "API vs no API." WIDS365's portals also use an API to reach the DB (wids365-api, called via a server-side AuthHttpClient). Both QIIUB and WIDS365 have an API. The difference is who calls the API and where the session lives:
WIDS365 โ server-rendered MVC
QIIUB โ SPA + API
Frontend
The server renders the HTML (Razor/MVC)
React SPA runs in the browser
Browser talks to
Only the MVC app (one origin)
The API directly, from JS
Who calls the API
The MVC server, server-to-server (invisible to the browser)
The browser's JavaScript
Session
Server-side cookie on the MVC app's own host (first-party)
Token/CSRF cookie the JS must read, on the SPA's host
Cross-origin cookie problem
None โ same-origin by construction
Yes โ hence the same-origin work
WIDS365 concretely (verified in code): the boarding/central app (wids365-central-mvc โ has TenantController, Register/SignUp views) and the operational portal (wids365-mvc) are each their own MVC app with its own AuthController + cookie authentication (AddCookie / SignInAsync). Each holds the session server-side on its own origin and calls wids365-api server-to-server. The browser never talks to the API directly, so there's no JS-readable-cookie-across-origins issue.
Takeaway: the MVC model is a server-side BFF โ the server holds the session and brokers the API, so it's same-origin by construction. QIIUB deliberately chose a browser SPA + API (ADR-0036: Vite + React, offline-first-friendly), which moves the token into the browser โ that's what creates the same-origin requirement. Both are valid; ours just has to do the same-origin step that MVC gets for free.
The SameSite / third-party-cookie future
Browsers have been tightening cross-site cookies for years (SameSite defaults, the third-party-cookie saga). What breaks is cross-site usage โ a cookie shared between different sites (e.g. Salesforce embedding Lightning inside a third-party page).
Same-origin puts us on the safe side. First-party cookies are immune to third-party-cookie and SameSite tightening. Going same-origin is exactly what sidesteps the Salesforce-style pain. (Google also abandoned full third-party-cookie removal in 2024, moving to a user-choice model.)
Detect early: a CI synthetic test (login โ F5 โ still signed in) across Chrome/Firefox/WebKit; cookie warnings in the browser console; a Sentry alert if auth-refresh 401s spike; cookie-attribute checks in the QA DAST. Fix if it ever bites: stay same-origin; keep __Secure- + correct SameSite; and for any genuine cross-site need use SameSite=None; Secure; Partitioned (CHIPS).
DDoS / WAF โ a separate layer (the decided edge = Azure Front Door)
Same-origin (Plan C) fixes the login/cookie problem. It gives you nothing against DDoS โ that is a different concern, handled at the edge, not in the app.
Baseline: Azure already provides infrastructure-level DDoS protection on public IPs for free.
App-layer (L7) DDoS + WAF โ the decided answer is Azure Front Door. Mike's subscription-conventions decision (2026-05-22) is Azure Front Door Standard ($35/mo) for MVP โ Premium ($330/mo) for PCI / multi-region / Private Link, with Azure DNS for qiiub.com (not Cloudflare) โ a single all-Azure pane for the enclave/PCI/multi-region future. Front Door in front of the App Service delivers same-origin and CDN + WAF + DDoS in one Azure-native layer.
Front Door billing (important): ~$35/mo base fee, billed hourly, charged even with zero traffic. It cannot be paused/disabled โ disabling endpoints stops data/request charges but the base fee runs until you delete the profile. So an idle Front Door in a disposable/parked env keeps billing โ a reason not to leave one standing in QA.
"Plan C makes Front Door optional" (for the login fix) means: you don't need Front Door just to get same-origin โ Plan C already does that for $0. Front Door is added for the edge concerns (WAF/DDoS/CDN/global), which is a prod need, not a QA one.
Scale, CDN & production sizing
A CDN (Content Delivery Network) keeps copies of your static files (the SPA's JS/CSS/images) on servers โ "edge POPs" โ near the user, all over the world. The browser fetches the app from the closest POP, not from your one compute server in one region. That means low latency globally, near-infinite scale for static bytes, and it's cheap.
Edge / SWA model SPA on a CDN
SPA served from the closest edge (fast worldwide, scales on its own, cheap). Only /api calls travel to the region.
Plan C SPA from compute
Every user fetches the SPA bundle from the single App Service in one region. Browser caching helps repeat loads, but first load / cache-miss crosses the world; and your compute instances spend cycles serving static bytes.
So Plan C's cost, restated: you give up the "static assets served from a global CDN edge" that the SWA gave you for free. Concretely โ higher first-load latency for distant users, static-file serving consuming your API compute, and static + API scaling as one unit (autoscale is coupled).
It's recoverable: you can put a CDN/edge (Cloudflare โ already in the stack โ or Azure Front Door) in front of the App Service to cache the SPA assets at the edge again, keeping same-origin. That re-adds the CDN benefit on top of Plan C (and brings the DDoS/WAF layer with it). The edge-proxy option gets this by default because the SPA already lives on the CDN.
Production sizing (not QA)
QA runs on App Service Plan B1 (Basic) on purpose โ lean/disposable, single instance, no autoscale, no deployment slots. Fine for demos, not for ~1,000 merchants.
Prod would be Standard (S1+) or, more likely, Premium v3 with autoscale (multiple instances by load) and deployment slots (deploy to a staging slot โ warm โ swap โ zero-downtime + instant rollback โ which removes the "a deploy restarts everything" concern under Plan C).
Exact SKU/instance count is a capacity/load-test decision on real traffic โ note the POS is offline-first, so API load is more sync-bursts + portal/admin usage than transaction-per-request, which can lower the requirement.
The decision interaction: at prod scale, slots make Plan C's downtime concern go away, but the CDN-edge point above argues for keeping static on an edge. Decide Plan-C-vs-edge with three lenses on the table โ cookies (#553), deploy independence + slots, and CDN-at-the-edge โ not just the cookie one.
Native clients โ mobile app and the POS
Native clients are not bound by the browser's origin/cookie rules. They hold their token in secure device storage and send it in the Authorization: Bearer header โ no cookies, no same-origin requirement. So the same-origin change for the web portals doesn't affect them. But the two native clients relate to the API differently:
Mobile app (networked): a normal online client. It calls the shared API cross-origin via CORS + Bearer tokens. Recommendation: one shared API, not a separate one โ the same API serves the browser (same-origin) and native (CORS) at once. Details decided when mobile lands; nothing here blocks it.
POS (Tauri, offline-first): it works locally against its own local store. Its primary channel to the platform is the sync connector (bidirectional reconcile) โ not general request-per-action API calls. Some operations (e.g. Returns) may end up as direct calls or go through an intermediary โ TBD. Either way it isn't governed by browser same-origin, and CORS (or the sync channel) covers it.
Takeaway: the same-origin work is a browser concern (the web portals). Native clients โ mobile via CORS+Bearer, POS via the sync connector โ are unaffected. The design deliberately keeps CORS alive for them.