Securing an API with a bearer token and securing a server-rendered web app with single sign-on are different problems that get conflated a lot. An API validates a token on every request. A web app needs a browser redirect to your identity provider, a session the user doesn't have to re-authenticate for on every page, and a logout that actually ends the session everywhere. Here's the pattern we use for the second one in ASP.NET Core.
- Web app SSO uses two authentication schemes together: a cookie scheme for the session, and an OpenIdConnect scheme that only runs during sign-in/sign-out.
- The Authorization Code flow with PKCE is the correct flow for a server-rendered app — the client secret (or certificate) stays on the server, never in the browser.
- Claim names vary by identity provider. Map them explicitly instead of assuming
ClaimTypes.NameorClaimTypes.Roleshow up for free. - Signing out means signing out of both schemes — the local cookie and the identity provider's session — or users end up “logged out” but still able to silently re-authenticate.
Two schemes, one login
The cookie scheme is what protects your pages day to day — it's what [Authorize] checks. The OpenIdConnect scheme only activates when a user needs to sign in: it redirects to your identity provider, handles the callback, and on success writes the result into a cookie so the OIDC round trip doesn't happen on every request.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
})
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = "code";
options.UsePkce = true;
options.SaveTokens = true;
options.Scope.Add("profile");
options.Scope.Add("email");
options.CallbackPath = "/signin-oidc";
});
In Program.cs, app.UseAuthentication() and app.UseAuthorization() go in the middleware pipeline as usual, and any page or endpoint marked [Authorize] triggers a challenge — a redirect to the identity provider — the first time an unauthenticated user hits it.
Mapping claims from your identity provider
Don't assume the standard ClaimTypes constants match what your identity provider sends. Entra ID, Okta, Auth0, and a self-hosted OpenIddict server all shape their ID token claims slightly differently. Map explicitly.
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "roles",
};
options.ClaimActions.MapUniqueJsonKey("department", "department");
Signing out of both places
A cookie-only sign-out logs the user out of your app but leaves their session alive at the identity provider — the next challenge silently re-authenticates them without a login prompt, which looks like sign-out didn't work. Sign out of both schemes so the OIDC handler also redirects to the provider's end-session endpoint.
app.MapGet("/account/signout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
});
SaveTokens = true stores the ID/access/refresh tokens in the authentication cookie. For providers that return large tokens, this can push the cookie past browser size limits. If you need the access token later (to call a downstream API on the user's behalf), consider a server-side token cache keyed by user ID instead of relying on the cookie.Local development callback URLs
Register a separate redirect URI for local development (https://localhost:5001/signin-oidc) alongside your real ones in the identity provider's client configuration — most providers require an exact match, and CORS or origin mismatches during local testing are the most common first-time setup issue.