How do I coordinate Microsoft Entra ID sign-in between a single-page app and a separately hosted backend API?
howto · identity · entra-id · oauth · oidc · spa · bff · cors
A bearer-token SPA runs MSAL.js itself and attaches its own access token to calls the API's CORS policy allows across origins - simplest to stand up, but every token lives in the browser. A backend-for-frontend (BFF) makes the backend the confidential OIDC client instead: it owns the redirect, keeps tokens server-side, and hands the SPA a same-origin cookie needing no CORS setup. Default to the BFF when the backend can own a session; reach for the bearer-token SPA only when the API also has to serve other, non-browser callers with the same token.
The question a split deployment adds
Section titled “The question a split deployment adds”identity/entra-id-client-sign-in covers signing a user into
a single app, SPA or server-rendered. Once the frontend and the API are two separate deployments -
different hosts, usually different origins - a second question opens up that a single-app setup never
asks: which side actually talks to Entra ID, and what does the other side receive from it? There are
two workable shapes, and they push the redirect URI, the CORS policy and where the token lives in
opposite directions.
Shape 1: the SPA runs MSAL.js and sends a bearer token cross-origin
Section titled “Shape 1: the SPA runs MSAL.js and sends a bearer token cross-origin”The frontend is registered as its own Single-page application app registration, exactly as in
identity/entra-id-client-sign-in,
with its redirect URI pointing at the SPA’s own origin (https://app.example.com). The API is a
second app registration, exposing one or more scopes under “Expose an API” (api://<api-client-id>/Widgets.Read);
the SPA requests tokens for that scope, MSAL.js caches and refreshes them the same way it would for
Microsoft Graph, and every call attaches Authorization: Bearer <token> to a request that crosses
origins:
const response = await fetch("https://api.example.com/widgets", { headers: { Authorization: `Bearer ${accessToken}` },});compiled and run by the build
Show the whole file
// The bearer-token SPA attaches its own access token to a request that// crosses origins. fetch is stubbed so the claim is checked against the// request it actually builds, with no network call and no live API.let seen;globalThis.fetch = async (url, init) => { seen = { url, init }; return new Response(JSON.stringify([]), { status: 200 });};
const accessToken = "token-1";
const response = await fetch("https://api.example.com/widgets", { headers: { Authorization: `Bearer ${accessToken}` },});
let failures = 0;function check(ok, what) { if (!ok) { console.error(`FAILED: ${what}`); failures++; }}
check(response.status === 200, "the stubbed request resolves");check(seen.url === "https://api.example.com/widgets", "the call goes to the API's own origin, not the SPA's");check(seen.init.headers.Authorization === "Bearer token-1", "the token is attached as a bearer credential");check(!("cookie" in seen.init), "no cookie is sent - the token is the only credential");
console.log(failures === 0 ? "entra-id-spa-separate-backend (javascript): all claims hold" : `${failures} FAILED`);process.exitCode = failures === 0 ? 0 : 1;Because the browser is making a cross-origin request with a non-simple header, it preflights with an
OPTIONS request first, and the API has to answer it before the real request is ever sent. The API’s
CORS policy must name the SPA’s exact origin and allow the Authorization header:
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy.WithOrigins("https://app.example.com") .AllowAnyHeader() .WithMethods("GET", "POST", "PUT", "DELETE")));compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12
// The API's CORS policy, registered exactly as the entry shows it and run// through ASP.NET Core's own CORS middleware and an in-memory TestServer -// no socket, no network call, no real API behind it.using Microsoft.AspNetCore.Cors.Infrastructure;using Microsoft.AspNetCore.TestHost;
var builder = WebApplication.CreateBuilder();builder.WebHost.UseTestServer();
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy.WithOrigins("https://app.example.com") .AllowAnyHeader() .WithMethods("GET", "POST", "PUT", "DELETE")));
// The cookie-carrying policy of a BFF its SPA calls from another origin.builder.Services.AddCors(options => options.AddPolicy("bff", policy => policy.WithOrigins("https://app.example.com") .AllowCredentials() .WithMethods("GET", "POST")));
var app = builder.Build();app.UseRouting();app.UseCors();app.MapGet("/widgets", () => Results.Ok(Array.Empty<string>())).RequireCors("spa");app.MapGet("/bff/widgets", () => Results.Ok(Array.Empty<string>())).RequireCors("bff");await app.StartAsync();
var failures = 0;void Check(bool ok, string what){ if (!ok) { Console.Error.WriteLine($"FAILED: {what}"); failures++; }}
var client = app.GetTestClient();
// The preflight that precedes the real request, from the SPA's own origin.var preflight = new HttpRequestMessage(HttpMethod.Options, "/widgets");preflight.Headers.Add("Origin", "https://app.example.com");preflight.Headers.Add("Access-Control-Request-Method", "GET");preflight.Headers.Add("Access-Control-Request-Headers", "Authorization");var preflightResponse = await client.SendAsync(preflight);
Check(preflightResponse.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigin) && allowedOrigin.Single() == "https://app.example.com", "the SPA's exact origin is allowed");Check(preflightResponse.Headers.TryGetValues("Access-Control-Allow-Headers", out var allowedHeaders) && allowedHeaders.Single().Split(',').Select(h => h.Trim()).Contains("Authorization", StringComparer.OrdinalIgnoreCase), "the Authorization header is allowed through the preflight");Check(!preflightResponse.Headers.Contains("Access-Control-Allow-Credentials"), "no cookie is involved, so AllowCredentials() was not needed and the header is absent");
// A different origin gets no preflight allowance at all.var elsewhere = new HttpRequestMessage(HttpMethod.Options, "/widgets");elsewhere.Headers.Add("Origin", "https://evil.example.com");elsewhere.Headers.Add("Access-Control-Request-Method", "GET");var elsewhereResponse = await client.SendAsync(elsewhere);Check(!elsewhereResponse.Headers.Contains("Access-Control-Allow-Origin"), "an origin the policy does not name is refused");
// The real request, after the preflight, from the same origin.var real = new HttpRequestMessage(HttpMethod.Get, "/widgets");real.Headers.Add("Origin", "https://app.example.com");real.Headers.Add("Authorization", "Bearer token-1");var realResponse = await client.SendAsync(real);Check(realResponse.IsSuccessStatusCode, "the real request, carrying the header the preflight allowed, succeeds");
// The BFF's credentialed policy names the exact origin and says so.var bffPreflight = new HttpRequestMessage(HttpMethod.Options, "/bff/widgets");bffPreflight.Headers.Add("Origin", "https://app.example.com");bffPreflight.Headers.Add("Access-Control-Request-Method", "GET");var bffResponse = await client.SendAsync(bffPreflight);Check(bffResponse.Headers.TryGetValues("Access-Control-Allow-Origin", out var bffOrigin) && bffOrigin.Single() == "https://app.example.com", "the credentialed policy echoes the exact origin, never *");Check(bffResponse.Headers.TryGetValues("Access-Control-Allow-Credentials", out var bffCredentials) && bffCredentials.Single() == "true", "the credentialed policy sends Access-Control-Allow-Credentials: true");
// ASP.NET Core's policy builder refuses a wildcard origin with credentials.var refused = false;try{ new CorsPolicyBuilder().AllowAnyOrigin().AllowCredentials().Build();}catch (InvalidOperationException){ refused = true;}Check(refused, "AllowAnyOrigin().AllowCredentials() throws when the policy is built");
await app.StopAsync();
Console.WriteLine(failures == 0 ? "entra-id-spa-separate-backend (csharp): all claims hold" : $"{failures} FAILED");return failures == 0 ? 0 : 1;No cookie is involved, so AllowCredentials() is not needed here - the token sits in the
Authorization header, not in a cookie the browser attaches automatically, and the API validates it
exactly as identity/validate-entra-id-access-tokens
describes. This is the shape to reach for when the API already has to serve callers MSAL.js knows
nothing about - a mobile app, a daemon, a second frontend - because the token it issues is the same
bearer credential every caller presents the same way.
The token never leaves the browser, and the preflight happens before the API ever sees the real request:
sequenceDiagram
participant B as Browser (SPA)
participant E as Entra ID
participant A as API
B->>E: sign in via MSAL.js
E-->>B: access token for API scope
B->>A: OPTIONS (preflight)
A-->>B: allow SPA origin, Authorization header
B->>A: GET /widgets, Authorization: Bearer
A->>A: validate token
A-->>B: responseShape 2: a backend-for-frontend owns the whole flow
Section titled “Shape 2: a backend-for-frontend owns the whole flow”A backend-for-frontend (BFF) is a small server component in front of the API (or built into it) that
is the confidential OpenID Connect client instead of the SPA: it is registered under the Web
platform type, its redirect URI is its own callback (https://api.example.com/signin-oidc), and it
holds the client secret the SPA, as a public client, never could. Signing in redirects the browser to
the BFF, which runs the authorization code exchange server-to-server - the same
Microsoft.Identity.Web wiring as the server-rendered variant in
identity/entra-id-client-sign-in
- and finishes by setting the SPA an encrypted,
HttpOnly,Secure,SameSite=Strictsession cookie. The SPA never sees an access token at all: it calls a local endpoint on the BFF (/bff/widgets), the BFF reads the session, attaches the real access token to a server-to-server call to the actual API, and returns the result.
The token stays on the BFF throughout, and the SPA holds only the session cookie:
sequenceDiagram
participant B as Browser (SPA)
participant F as BFF
participant E as Entra ID
participant A as API
B->>F: GET /bff/widgets, no session
F-->>B: redirect to Entra ID
B->>E: sign in
E-->>B: redirect to /signin-oidc + code
B->>F: GET /signin-oidc?code
F->>E: redeem code, client secret
E-->>F: access token
F-->>B: Set-Cookie, HttpOnly session
B->>F: GET /bff/widgets, cookie
F->>A: GET /widgets, Authorization: Bearer
A-->>F: response
F-->>B: responseWhen the BFF and the SPA’s static files are served from the same origin - a reverse proxy routing /
to the SPA’s assets and /bff/* to the backend is the usual way to arrange that even when they are
built and deployed separately - the SPA’s calls to its own backend are same-origin. No CORS
configuration is needed at all, because a same-origin fetch never preflights and the cookie is
attached automatically by the browser. This is the shape that keeps every token out of code an XSS bug
in a browser dependency could read, at the cost of one more deployed component and one more network
hop for every API call.
The trap: a cross-origin BFF, or mixing the two shapes
Section titled “The trap: a cross-origin BFF, or mixing the two shapes”Serving the SPA from a different origin than the BFF (a separate static host, a separate CDN host)
does not remove the BFF’s benefit, but it does bring CORS back. The SPA’s fetch has to ask for the
cookie with credentials: "include", and the BFF’s CORS policy must name the SPA’s exact origin and
allow credentials:
builder.Services.AddCors(options => options.AddPolicy("bff", policy => policy.WithOrigins("https://app.example.com") .AllowCredentials() .WithMethods("GET", "POST")));compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12
// The API's CORS policy, registered exactly as the entry shows it and run// through ASP.NET Core's own CORS middleware and an in-memory TestServer -// no socket, no network call, no real API behind it.using Microsoft.AspNetCore.Cors.Infrastructure;using Microsoft.AspNetCore.TestHost;
var builder = WebApplication.CreateBuilder();builder.WebHost.UseTestServer();
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy.WithOrigins("https://app.example.com") .AllowAnyHeader() .WithMethods("GET", "POST", "PUT", "DELETE")));
// The cookie-carrying policy of a BFF its SPA calls from another origin.builder.Services.AddCors(options => options.AddPolicy("bff", policy => policy.WithOrigins("https://app.example.com") .AllowCredentials() .WithMethods("GET", "POST")));
var app = builder.Build();app.UseRouting();app.UseCors();app.MapGet("/widgets", () => Results.Ok(Array.Empty<string>())).RequireCors("spa");app.MapGet("/bff/widgets", () => Results.Ok(Array.Empty<string>())).RequireCors("bff");await app.StartAsync();
var failures = 0;void Check(bool ok, string what){ if (!ok) { Console.Error.WriteLine($"FAILED: {what}"); failures++; }}
var client = app.GetTestClient();
// The preflight that precedes the real request, from the SPA's own origin.var preflight = new HttpRequestMessage(HttpMethod.Options, "/widgets");preflight.Headers.Add("Origin", "https://app.example.com");preflight.Headers.Add("Access-Control-Request-Method", "GET");preflight.Headers.Add("Access-Control-Request-Headers", "Authorization");var preflightResponse = await client.SendAsync(preflight);
Check(preflightResponse.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigin) && allowedOrigin.Single() == "https://app.example.com", "the SPA's exact origin is allowed");Check(preflightResponse.Headers.TryGetValues("Access-Control-Allow-Headers", out var allowedHeaders) && allowedHeaders.Single().Split(',').Select(h => h.Trim()).Contains("Authorization", StringComparer.OrdinalIgnoreCase), "the Authorization header is allowed through the preflight");Check(!preflightResponse.Headers.Contains("Access-Control-Allow-Credentials"), "no cookie is involved, so AllowCredentials() was not needed and the header is absent");
// A different origin gets no preflight allowance at all.var elsewhere = new HttpRequestMessage(HttpMethod.Options, "/widgets");elsewhere.Headers.Add("Origin", "https://evil.example.com");elsewhere.Headers.Add("Access-Control-Request-Method", "GET");var elsewhereResponse = await client.SendAsync(elsewhere);Check(!elsewhereResponse.Headers.Contains("Access-Control-Allow-Origin"), "an origin the policy does not name is refused");
// The real request, after the preflight, from the same origin.var real = new HttpRequestMessage(HttpMethod.Get, "/widgets");real.Headers.Add("Origin", "https://app.example.com");real.Headers.Add("Authorization", "Bearer token-1");var realResponse = await client.SendAsync(real);Check(realResponse.IsSuccessStatusCode, "the real request, carrying the header the preflight allowed, succeeds");
// The BFF's credentialed policy names the exact origin and says so.var bffPreflight = new HttpRequestMessage(HttpMethod.Options, "/bff/widgets");bffPreflight.Headers.Add("Origin", "https://app.example.com");bffPreflight.Headers.Add("Access-Control-Request-Method", "GET");var bffResponse = await client.SendAsync(bffPreflight);Check(bffResponse.Headers.TryGetValues("Access-Control-Allow-Origin", out var bffOrigin) && bffOrigin.Single() == "https://app.example.com", "the credentialed policy echoes the exact origin, never *");Check(bffResponse.Headers.TryGetValues("Access-Control-Allow-Credentials", out var bffCredentials) && bffCredentials.Single() == "true", "the credentialed policy sends Access-Control-Allow-Credentials: true");
// ASP.NET Core's policy builder refuses a wildcard origin with credentials.var refused = false;try{ new CorsPolicyBuilder().AllowAnyOrigin().AllowCredentials().Build();}catch (InvalidOperationException){ refused = true;}Check(refused, "AllowAnyOrigin().AllowCredentials() throws when the policy is built");
await app.StopAsync();
Console.WriteLine(failures == 0 ? "entra-id-spa-separate-backend (csharp): all claims hold" : $"{failures} FAILED");return failures == 0 ? 0 : 1;What the cookie needs depends on something else: whether the two are on the same site, which is
not the same as the same origin. An origin is scheme, host and port. A site is only the scheme and
the registrable domain (example.com in app.example.com), and SameSite is decided by the site.
So an SPA on https://app.example.com calling a BFF on https://api.example.com makes a
cross-origin but same-site request: it needs the credentialed CORS policy above, but a
SameSite=Strict or Lax session cookie is still sent. Only a host on another registrable domain
(the SPA on example-cdn.net, say) makes the request cross-site. Then the cookie must be marked
SameSite=None; Secure to be sent at all, and even that is not enough everywhere: to the browser it
is now a third-party cookie, and browsers that block those by default (Safari and Brave today) do
not send it whatever its attributes say. A BFF whose SPA lives on another site is therefore broken
for part of its users, and the fix is to move one of them under the other’s registrable domain or
behind the same reverse proxy, not to tune the cookie.
The credentialed policy cannot be a wildcard either. A browser refuses a credentialed response
whose Access-Control-Allow-Origin is *, by the Fetch standard’s CORS check, because a
credentialed wildcard would let any site on the internet ride the session cookie. ASP.NET Core
refuses it earlier still: a policy built with AllowAnyOrigin().AllowCredentials() throws
InvalidOperationException when the policy is built, before any request is answered, as the example
above asserts. Not every server library
does; some offer to reflect whatever Origin the request carries back as the allowed origin, which
the browser accepts: the same open door with the wildcard’s safety check taken off. Name the
origins. The other trap is running both
shapes on the same app at once - MSAL.js caching a real access token in the browser and a BFF issuing
a session cookie for the same login - which keeps the token-exfiltration surface Shape 2 exists to
remove while adding Shape 2’s extra hop on top of it, with neither tradeoff actually paid off.
Variant: a hybrid app already doing both server and browser auth
Section titled “Variant: a hybrid app already doing both server and browser auth”An app that is a confidential client for its own server-rendered shell and also runs MSAL.js in the
browser for some part of the same page - not a wholly separate API, but the same application in both
roles - would otherwise sign the user in twice: interactively on the server, then silently again in the
browser through a hidden iframe, which is slow and fails outright when the browser blocks
third-party cookies. MSAL’s SPA authorization code flow removes the second sign-in: the confidential
client asks for an extra authorization code for the browser while it redeems its own
(AcquireTokenByAuthorizationCode(...).WithSpaAuthorizationCode(true) in MSAL.NET 4.40 and later,
which returns it as SpaAuthCode), renders that code into the page, and MSAL.js’s
acquireTokenByCode redeems it. The redemption is still a call to Entra ID’s /token endpoint from
the browser; what the flow skips is the hidden-iframe step, not Entra ID. The redirect URI the
server signs in with stays of type Web, because the code is minted for the confidential client
first. This solves a different problem than shapes 1 and 2 above - one app wearing both hats -
not the two-deployment split this entry is about.
How to verify it worked
Section titled “How to verify it worked”For the bearer-token SPA, the browser’s network tab shows a preflight OPTIONS request to the API
returning Access-Control-Allow-Origin matching the SPA’s exact origin, followed by the real request
carrying the Authorization header the API accepts; a mismatched or missing CORS header shows up as a
console error naming CORS explicitly, not as the request simply failing silently. For the BFF, open the
browser’s storage inspector after signing in: there should be no access or ID token in localStorage,
sessionStorage, or any variable inspectable from the page’s own JavaScript, only the session cookie,
and the network tab should show the SPA’s calls to the BFF carrying no Authorization header and no
preflight OPTIONS request when SPA and BFF share an origin. When they do not, each call to the BFF
should carry the session cookie in its Cookie request header, and its response should carry
Access-Control-Allow-Origin with the SPA’s exact origin and Access-Control-Allow-Credentials: true;
a call that reaches the BFF without the cookie had it held back by the browser, and the request’s
cookies in the network tab (filtered-out cookies shown) name the reason: SameSite on a cross-site
request, or third-party cookie blocking. Killing the BFF’s session (or clearing
the cookie) and repeating a call should get a 401 from the BFF itself, not a token error surfaced
from Entra ID, because the SPA in this shape never talks to Entra ID directly.
When this does not apply
Section titled “When this does not apply”A frontend and API built and deployed as a single unit - the API serving the SPA’s static files itself,
or both behind the same host with no separate origin - has nothing to coordinate: it is the
single-app case identity/entra-id-client-sign-in already
covers, with no cross-origin call and no proxy hop to add. A native or mobile app calling the API has
no browser and no CORS concept either way, and follows
dotnet/jwt-authentication-desktop-client instead. A
service calling the API with no interactive user present needs the client-credentials variant covered
in identity/oauth-oidc-sso-entra-id, which
has no SPA, no session cookie and no CORS preflight to reason about at all.
History: 5 changes
- Body changed; example changed
- Example changed
- Body changed; example changed
- Made public
- Created