Authentication
Commerce runs a cookie session in front of Entra External ID. The browser never holds a token — token acquisition, the OIDC exchange and claim mapping all happen server-side.
Why a session and not a token
The obvious alternative is the SPA holding an access token and sending it as a bearer header. Commerce does not do that, and the reasons compound.
A token in the browser has to live somewhere JavaScript can read, which makes cross-site scripting an account-takeover bug rather than a defacement. Refresh logic then has to be implemented in every front end, correctly, including the race where two tabs refresh at once. And the storefront becomes a confidential client that is not confidential — it ships its own configuration to anyone who views source.
Keeping the session server-side collapses all of that into one HttpOnly cookie the browser cannot
read and cannot leak. The cost is that Commerce must be reachable on an origin the browser will send
cookies to, which is what the trusted-origins list below exists to control.
The sign-in flow
sequenceDiagram
autonumber
participant B as Browser
participant C as Commerce
participant E as Entra External ID
B->>C: GET /auth/me
C-->>B: 401 — no cookie, and no redirect
B->>C: GET /auth/login?returnUrl=…
C-->>B: 302 to Entra authorize
B->>E: interactive sign-in
E-->>C: 302 back with the result
C->>C: validate · map claims · bridge to a local User
C-->>B: Set-Cookie, then 302 to returnUrl
B->>C: GET /api/ui/… with the cookie
C->>C: active session → which customer this request acts on
/auth/me returning 401 rather than a redirect is the first design choice on this page, and the
one most likely to look wrong to someone who has built a server-rendered app. It is covered below.
Bridging to a local user is what makes the rest of the system work: the Entra principal is mapped to a
User row and stamped with portal claims — user id, contact and account numbers, host-admin flag,
country. Other domains can add claims of their own through a principal-enricher seam, so a domain does
not need the host to know about its claims.
The three endpoints a browser sees
| Endpoint | Purpose |
|---|---|
/auth/login?returnUrl=… |
Starts sign-in. On success, sets the cookie and returns to returnUrl. |
/auth/logout?returnUrl=… |
Ends both the local session and the Entra one. |
/auth/me |
The current identity, or 401. |
That is the entire surface. A storefront — reference or bespoke — needs nothing else to authenticate, which is what makes replacing the front end a small job.
Four schemes, four kinds of caller
| Scheme | Authenticates | Notes |
|---|---|---|
| Cookie | Storefront and admin users | The default scheme, and the default challenge scheme |
| OpenID Connect | Only /auth/login |
Challenged explicitly by name; never implicit |
| Bearer JWT | Integration callers pushing data in | System-to-system, unrelated to the user flow |
| API key | The public API | Doubles as the rate-limit partition key |
Why Cookie is the default challenge scheme
Most .NET applications set the challenge scheme to OpenID Connect, so an unauthenticated [Authorize]
request redirects to the identity provider. For a cross-origin API that redirect is unusable: a
browser fetch cannot follow a 302 to Entra, because Entra serves no CORS headers on its authorize
endpoint. The SPA sees TypeError: Failed to fetch — no status code, nothing to branch on.
Challenging with Cookie makes the handler return a plain 401 for anything under /auth or /api,
which is the correct contract for an API and something a client can actually act on. /auth/login
opts into OIDC by naming the scheme in the challenge.
A future server-rendered page that should redirect can opt in per-endpoint.
The cookie
| Attribute | Value | Why |
|---|---|---|
HttpOnly |
true | JavaScript cannot read it — an XSS bug cannot exfiltrate the session |
Secure |
always | HTTPS only |
SameSite |
None |
Required for a cross-origin storefront to send it at all |
| Lifetime | 8 hours, configurable | Inactivity timeout |
| Sliding | true | Any authenticated request renews it |
SameSite=None looks alarming in isolation. It does not weaken anything here, because the
trusted-origins list controls which origins may make credentialed requests in the first place — and
that list is doing more work than it appears to.
Trusted origins do two separate jobs
One configured list, two independent controls. Missing the second is a live security hole, not a tidiness issue.
CORS
Which origins may make credentialed calls with the user's existing session. Applied between routing and authentication, so a preflight is answered before anything tries to authenticate it.
Stops a malicious page from calling Commerce as the signed-in user.
returnUrl validation
Where sign-in and sign-out are allowed to send the browser afterwards. CORS does not cover this — a redirect is a 302, not a scripted fetch.
Stops /auth/login?returnUrl=https://attacker.example. Rejected values fall back silently to /.
Two details in the comparison are deliberate and easy to undo by accident:
Origins are compared by equality, not by prefix. A prefix match would accept
https://good.com.attacker.example against a trusted https://good.com. The earlier implementation
carried exactly that weakness.
A rejected returnUrl fails silently — no 400, no warning logged. A phishing probe learns nothing
about what is trusted. The trade-off is that there is no telemetry on rejections either, which is a
known and accepted gap.
Important
Adding a trusted origin means an exact scheme, host and port — no path, no trailing slash — and a restart, because both the CORS policy and the pipeline gate read the list at startup. Anything served from a trusted origin can call Commerce with the user's session and be a redirect target, so an origin nobody owns any more should be removed rather than left in place.
Which customer a request acts on
Authentication answers who the user is. It does not answer which customer they are acting for — and a user may belong to more than one, while the client's own service staff may reach all of them.
That resolution happens per request, under /api/ui only, from the user's active session row —
not from a claim in the cookie.
Why not a claim. Baking the customer into the cookie makes switching customer a cookie re-issue, and makes a stale cookie address the wrong customer's data — a data-leak shape rather than an inconvenience. A row lookup costs a query and makes switching a single update.
Everything outside /api/ui runs unscoped: admin, integration, the login pipeline itself, and
background handlers. What that does to database queries is on
Database.
Authorization
Authentication is only half of it. Every controller in the application carries an authorization attribute — admin surfaces behind a host-admin or portal-user policy, integration behind its own policy on the bearer scheme, the public API behind its key scheme and rate limit, and storefront endpoints behind a customer-role requirement that fails when a request is unscoped.
Note
An earlier version of the security documentation records that every endpoint outside /auth/* was
implicitly anonymous, pending an auth-model port. That is no longer true — the port has landed
and all 34 controllers are covered. If you find a document saying otherwise, it predates the change.
Related
docs/Security/bff-authentication.md— cookie attributes, the origin comparison and the sign-in flow, with file and line citationsdocs/Security/external-site-integration-guide.md— what an external integrator is told