OpenID Connect with an External Provider
Sign users in through Entra ID, Auth0 or Keycloak with AddOpenIdConnect, code flow and PKCE, and run a BFF that keeps tokens off the browser.
.AddCookie()
.AddOpenIdConnect(o =>
{
o.Authority = "https://login.example.com/realms/shop";
o.ClientId = "shop-bff";
o.ResponseType = "code";
o.SaveTokens = true;
});OpenID Connect is how a server-side app signs a user in at an identity provider. AddOpenIdConnect (package Microsoft.AspNetCore.Authentication.OpenIdConnect) is a remote scheme: its challenge redirects the browser to the provider, and the provider sends the browser back to /signin-oidc with a code. The handler then exchanges the code for tokens, validates the ID token, and signs the user into a local scheme, almost always the cookie. Resource APIs never use it; they validate access tokens with JWT bearer.
Code flow with PKCE
- Challenge: redirect to the authorize endpoint with
client_id,redirect_uri,scope,state,nonceand a PKCEcode_challenge; correlation and nonce cookies are set. - The user authenticates at the provider (password, MFA, passkey: none of it touches your app).
- The provider POSTs
codeandstateto/signin-oidc(response_mode=form_post). - The handler redeems the code with the client secret and the PKCE verifier, validates the ID token, builds the principal and calls
SignInAsyncon the cookie scheme.
Watch the defaults: ResponseType is id_token, the implicit-style flow, so set ResponseType = "code". UsePkce is already true, and pushed authorization requests are used when the provider advertises them. SaveTokens is false; set it to keep the access and refresh tokens inside the encrypted cookie for later API calls. Scopes default to openid profile; add offline_access for a refresh token and the API's scopes.
Providers
| Provider | Authority | Watch for |
|---|---|---|
| Entra ID | https://login.microsoftonline.com/{tenant-id}/v2.0 | API scopes look like api://orders-api/orders.read; Microsoft.Identity.Web wraps this setup |
| Auth0 | https://{tenant}.auth0.com/ | Send an audience parameter or the access token is not a JWT for your API |
| Keycloak | https://{host}/realms/{realm} | Roles sit in realm_access.roles; map them with claims transformation |
BFF pattern
A backend-for-frontend runs the OIDC flow for a single-page app: the SPA calls /bff/login, gets an HttpOnly cookie, and calls /bff/... endpoints that attach the saved access token to downstream API calls. Tokens never reach JavaScript, which is the current recommendation for browser apps over storing tokens in the SPA. Keep the cookie as the default challenge scheme and challenge OIDC only in /bff/login: a 302 to the provider breaks fetch with a CORS error, while a 401 lets the SPA navigate to /bff/login.
Production failure modes
- redirect_uri mismatch behind a proxy: TLS ends at the load balancer, the app sees
httpand buildshttp://.../signin-oidc. Configure forwarded headers before authentication. - Correlation failed: the correlation cookie is
SameSite=Noneand therefore requires HTTPS; it is also lost when the callback lands on another host name. - Logged out at the API but not the app: the cookie outlives the access token. Refresh the token, or cap the cookie lifetime to the token lifetime.
- Open redirect: validate
returnUrlas a local path before putting it inRedirectUri.
Example
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services
// The cookie stays the challenge scheme, so fetch calls to /bff/* get 401, not a 302 to the provider.
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "__Host-shop";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax; // Strict drops the cookie on the redirect back from the provider
// Provider access tokens last minutes, not hours: before production, add an OnValidatePrincipal
// step that checks expires_at and redeems the refresh_token, or rejects the principal.
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
})
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.SaveTokens = true;
options.MapInboundClaims = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("offline_access");
options.Scope.Add("orders.read");
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
});
builder.Services.AddAuthorization();
builder.Services.AddHttpClient("orders-api", client =>
client.BaseAddress = new Uri(builder.Configuration["OrdersApi:BaseUrl"] ?? "https://orders.internal.example.com/"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
var bff = app.MapGroup("/bff").DisableCookieRedirect();
// The only place the provider is challenged; the SPA navigates here after a 401 from /bff/*.
bff.MapGet("/login", (string? returnUrl) =>
TypedResults.Challenge(new AuthenticationProperties { RedirectUri = LocalPathOrRoot(returnUrl) },
[OpenIdConnectDefaults.AuthenticationScheme]));
bff.MapPost("/logout", () =>
TypedResults.SignOut(new AuthenticationProperties { RedirectUri = "/" },
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]))
.RequireAuthorization();
bff.MapGet("/user", (ClaimsPrincipal user) =>
TypedResults.Ok(new { name = user.Identity?.Name, email = user.FindFirstValue("email") }))
.RequireAuthorization();
// The SPA calls the BFF; the BFF calls the API with the user's access token.
bff.MapGet("/orders", async (HttpContext context, IHttpClientFactory clients, CancellationToken cancellationToken) =>
{
var accessToken = await context.GetTokenAsync("access_token");
if (accessToken is null) return Results.Unauthorized();
var client = clients.CreateClient("orders-api");
using var request = new HttpRequestMessage(HttpMethod.Get, "orders/mine");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.SendAsync(request, cancellationToken);
return Results.Content(await response.Content.ReadAsStringAsync(cancellationToken), "application/json",
statusCode: (int)response.StatusCode);
}).RequireAuthorization();
app.Run();
static string LocalPathOrRoot(string? returnUrl) =>
returnUrl is { Length: > 0 } && returnUrl[0] == '/' && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\")
? returnUrl
: "/";When to use it
- Let shop staff sign in to the back-office BFF with their company Entra ID accounts, including the company's MFA rules.
- Run a BFF for a React storefront that signs customers in through Keycloak and calls orders-api with their access token.
- Fix a login loop where Keycloak rejects redirect_uri=http://shop.example.com/signin-oidc behind the load balancer.
More examples
Keycloak settings with the secret kept out of the file
The client secret identifies the BFF to the provider when it redeems the code, so it is a real credential: inject it from Key Vault, AWS Secrets Manager or a Kubernetes Secret, and rotate it on the provider side with an overlap. In Keycloak, register exactly <code>https://shop.example.com/signin-oidc</code> and <code>https://shop.example.com/signout-callback-oidc</code> as redirect URIs; wildcard redirect URIs are how authorization codes get stolen.
{
"Oidc": {
"Authority": "https://id.example.com/realms/shop",
"ClientId": "shop-bff",
// Supplied by the secret store as the Oidc__ClientSecret environment variable.
"ClientSecret": "<from-secret-store>"
},
"OrdersApi": {
"BaseUrl": "https://orders.internal.example.com/"
},
"ForwardedHeaders": {
"KnownProxyCidr": "10.0.0.0/8"
}
}Pitfall: http redirect_uri behind a load balancer
The provider compares <code>redirect_uri</code> character for character with the registered value. Behind a TLS-terminating proxy the app sees <code>http</code> and the internal host, the provider answers with an invalid redirect URI error, and nobody can log in. Trust forwarded headers only from the proxy's network; accepting them from anywhere lets a client spoof its scheme, host and IP. The forwarded headers lesson covers <code>KnownIPNetworks</code> and <code>KnownProxies</code> in detail.
using System.Net;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8")); // the load balancer's subnet
options.AllowedHosts = ["shop.example.com"];
});
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = "code";
});
var app = builder.Build();
// Must run before authentication, which builds redirect_uri from Request.Scheme and Request.Host.
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", () => "Shop").RequireAuthorization();
app.Run();Auth0: asking for an access token for your API
Without the <code>audience</code> parameter Auth0 returns an access token meant only for its own userinfo endpoint, and every call from the BFF to your API fails JWT bearer validation with an invalid audience or signature. <code>OnRedirectToIdentityProvider</code> is the hook for any provider-specific authorize parameter, such as <code>prompt=login</code> for step-up or <code>acr_values</code> to demand MFA.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://shop-prod.eu.auth0.com/";
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = "code";
options.SaveTokens = true;
options.Scope.Add("orders.read");
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
// Auth0 issues a JWT access token for an API only when the audience is requested.
context.ProtocolMessage.SetParameter("audience", "https://api.shop.example.com");
return Task.CompletedTask;
},
};
});
var app = builder.Build();
app.MapGet("/", () => "Shop").RequireAuthorization();
app.Run();
Discussion