Wiki ๐Ÿ” Search

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.

Created: 2026-07-06 | Last Updated: 2026-07-07

Contents: Glossary ยท The problem ยท What breaks if done wrong ยท The options (A/B/C + edge) ยท Decision & environments ยท Industry proof ยท MVC vs SPA (why existing apps avoid it) ยท SameSite future ยท DDoS / WAF ยท Scale, CDN & prod sizing ยท Mobile app

Glossary (plain English)

Origin
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

admin.qa.qiiub.com Portal (SPA) JS runs here api.qa.qiiub.com API ๐Ÿ”‘ CSRF cookie โœ• portal can't reach the key

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

admin.qa.qiiub.com Portal (SPA / wwwroot) /api ๐Ÿ”‘ same address โ†’ key is in reach

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

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.)

OptionHow it worksCostDeploysVerdict
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. Independent Rejected โ€” cost + auto Easy-Auth gate blocks native (Tauri/mobile) clients.
B โ€” SWA route rewrite 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 Service chosen / 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 Door prod 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:

EnvironmentSame-origin viaEdge (CDN/WAF/DDoS)Cost
Local devVite dev proxyโ€”$0
QAPlan C (App Service serves SPAs)none (not needed)$0
ProdAzure Front Door routes /apiFront 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 unless wwwroot/{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 /api verbatim and pass cookies (Path=/api/auth) โ€” a config concern, not code.

Is this industry-standard? Yes.

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 MVCQIIUB โ€” SPA + API
FrontendThe server renders the HTML (Razor/MVC)React SPA runs in the browser
Browser talks toOnly the MVC app (one origin)The API directly, from JS
Who calls the APIThe MVC server, server-to-server (invisible to the browser)The browser's JavaScript
SessionServer-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 problemNone โ€” same-origin by constructionYes โ€” 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.

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

API 1 region SPA SPA SPA SPA edge POPs near users

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

App Svc SPA + /api 1 region (Canada C.) every user โ†’ one region

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.

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:

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.