OpenID Connect: ID Tokens, UserInfo, Discovery, AddOpenIdConnect in ASP.NET Core and Logout Types
An ID token is a sign-in record written for the client, an access token is a permission written for the API; mixing them up is a vulnerability. In a Keycloak lab we read discovery, JWKS and UserInfo, show how MapInboundClaims renames claims in AddOpenIdConnect, trace RP-initiated logout, and write a back-channel logout endpoint that drops this app's cookie when the user signs out of another app.
Version note: The code in this post was built and run with .NET 10, Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.14 and Keycloak 26.8.2.
Guess First
Bookstore's web app signs in with Keycloak. User.FindFirst("sub") returns null, yet you know the ID token contains sub. Why? Second question: Ada signed out of the app; the app's cookie was deleted. When Ada clicks "Sign in" again, is she asked for a password? Third: if Ada signs out of another Bookstore app she entered with the same Keycloak session, what happens to her session in this web app?
All three answers live in the layer OIDC (OpenID Connect) adds on top of OAuth. In post 10 we saw that OAuth is not an authentication protocol; in post 11, how a code becomes tokens. In this post we look at what OIDC brings, namely the ID token, UserInfo and discovery, how ASP.NET Core's AddOpenIdConnect handles them, and the most neglected topic of all: logout. The lab is the same as in the previous post: the bookstore realm in Keycloak and the confidential client bookstore-web.
ID Token vs Access Token: Who Is It Written For?
In the previous post two JWTs (JSON Web Tokens) came back from the token endpoint together. Both are signed with the same Keycloak key and both carry sub, but their readers differ:
| ID token | Access token | |
|---|---|---|
| Written for | The client (aud = bookstore-spa) | The resource server (aud = bookstore-api) |
| Describes | A sign-in event: who, when, how | A permission: which API (Application Programming Interface), with which scopes |
| Validated by | The client, field by field | The API; opaque to the client |
| Sent to | Nowhere (only as id_token_hint at logout) | The API in the Authorization: Bearer header |
| Standard | OIDC Core 1.0 | OAuth 2.0; JWT profile in RFC (Request for Comments) 9068 |
The ID token's claims describe the sign-in event: iss (who issued it), sub (the user's stable identity within the issuer), aud (for which client), exp/iat, auth_time (when the user last actually authenticated), nonce, acr (the level of authentication) and, if present, amr (the methods used). sid is the identifier of the session in Keycloak; we will need it at logout. Remember that we made the step-up decision in post 05 based on acr and auth_time.
Always identify a user by the iss + sub pair. Emails change, and the same email at two different IdPs (Identity Providers) can belong to two different people; that is the root of the "two accounts with the same email" problem from post 04.
Discovery and JWKS
Everything the client needs to know about Keycloak is at one address:
$ curl -s http://localhost:19200/realms/bookstore/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, ...}'
{
"issuer": "http://localhost:19200/realms/bookstore",
"authorization_endpoint": "http://localhost:19200/realms/bookstore/protocol/openid-connect/auth",
"token_endpoint": "http://localhost:19200/realms/bookstore/protocol/openid-connect/token",
"userinfo_endpoint": "http://localhost:19200/realms/bookstore/protocol/openid-connect/userinfo",
"end_session_endpoint": "http://localhost:19200/realms/bookstore/protocol/openid-connect/logout",
"jwks_uri": "http://localhost:19200/realms/bookstore/protocol/openid-connect/certs",
"id_token_signing_alg_values_supported": [
"PS384",
"RS384",
"EdDSA",
"ES384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512"
],
"subject_types_supported": [
"public",
"pairwise"
],
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"frontchannel_logout_supported": true
}
When you set Authority, the first thing ASP.NET Core does is download this document; that is the "Updating configuration" line in the log. Then it fetches the public keys from jwks_uri:
$ curl -s http://localhost:19200/realms/bookstore/protocol/openid-connect/certs | jq '.keys[] | {kid, kty, alg, use, n: (.n[0:16]+"..."), e}'
{
"kid": "3xDZ-2W6Nlja7hkWMS5oNwllmASgYJ8e3OK9yN82h9Y",
"kty": "RSA",
"alg": "RSA-OAEP",
"use": "enc",
"n": "jU3lpk0IvCg6jl3X...",
"e": "AQAB"
}
{
"kid": "-bTt6-Fb7_swq5REoKGPi3jdsUdOOwmbRw1ZG2hLKAY",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "3UQHpYVHPxAAKmTw...",
"e": "AQAB"
}
There are two keys: the use: sig one for verifying signatures and the use: enc one for clients that want encrypted tokens (JWE, JSON Web Encryption). The kid in the ID token header matches the signing key. In post 08 we wrote our own JWKS (JSON Web Key Set) endpoint and rotated keys by hand; here the authorization server runs the same mechanism, and the handler refetches configuration when it sees an unknown kid. One detail: HS256 (Hash-based Message Authentication Code, signing with a shared secret) is in the id_token_signing_alg_values_supported list too. In Keycloak the signature algorithm can be chosen per client, so narrowing the algorithms the handler accepts with TokenValidationParameters.ValidAlgorithms is still a good idea.
These documents are "OpenID Connect Discovery 1.0" and "OpenID Connect Core 1.0". In 2024, nine OIDC specifications including Core were also published as ISO (International Organization for Standardization)/IEC (International Organization for Standardization / International Electrotechnical Commission) standards; Core's number is ISO/IEC 26131:2024. If you see that number in enterprise procurement documents, they mean OIDC Core.
UserInfo: What Doesn't Fit in the Token
The UserInfo endpoint is an OIDC endpoint called with an access token that returns the user's claims as JSON (JavaScript Object Notation):
$ curl -s $B/userinfo -H "Authorization: Bearer $AT" | jq .
{
"sub": "813c410a-8e19-4979-85aa-5cc46f3cb5ab",
"email_verified": true,
"name": "Ada Lovelace",
"preferred_username": "ada@bookstore.test",
"given_name": "Ada",
"family_name": "Lovelace",
"email": "ada@bookstore.test"
}
$ curl -s -D - -o /dev/null $B/userinfo -H "Authorization: Bearer $IDT"
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="bookstore", error="invalid_token", error_description="Token verification failed"
The second request tries to send the ID token instead of the access token and is rejected. Good; an ID token is not a key to an API. Keeping the ID token small and fetching large or frequently changing claims (address, profile picture, long group lists) from UserInfo also keeps the cookie small.
AddOpenIdConnect and Claim Mapping
The lab web app's OIDC settings:
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.RequireHttpsMetadata = false; // local lab only
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.Scope.Add("orders.read");
options.MapInboundClaims = builder.Configuration.GetValue("Oidc:MapInboundClaims", true);
options.GetClaimsFromUserInfoEndpoint = builder.Configuration.GetValue("Oidc:UserInfo", false);
options.ClaimActions.Remove("acr"); // acr is deleted by default; we keep it for step-up
options.TokenValidationParameters.NameClaimType = "name";
options.SaveTokens = true;
});
app.MapGet("/claims", (ClaimsPrincipal user) =>
user.Claims.Select(c => $"{c.Type} = {c.Value}")).RequireAuthorization();
The Scope list contains openid and profile by default; I added orders.read. MapInboundClaims defaults to true. Signing in with that setting and looking at /claims:
auth_time = 1794116170
jti = 8f748bff-df85-f6fb-712a-5ded33e294e2
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier = 813c410a-8e19-4979-85aa-5cc46f3cb5ab
typ = ID
sid = E5BxuRtA1R8W44aQ8d0aDVFo
http://schemas.microsoft.com/claims/authnclassreference = 1
email_verified = true
name = Ada Lovelace
preferred_username = ada@bookstore.test
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname = Ada
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname = Lovelace
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress = ada@bookstore.test
Here is the answer to the first question: there is no sub; in its place is a long URI (Uniform Resource Identifier) left over from the Web Services Federation (WS-Federation) era. The token handler translates well-known short JWT claim names into .NET's old ClaimTypes constants; even acr became authnclassreference. That is convenient for old code that uses ClaimTypes.NameIdentifier, but the name in the token and the name in code diverge, logs become unreadable, and if another handler (say JWT Bearer) is configured differently, the same user shows up with two different claim sets. The same sign-in with Oidc:MapInboundClaims=false:
auth_time = 1794116182
jti = ffe7d692-f882-5794-aa44-15925343e94b
sub = 813c410a-8e19-4979-85aa-5cc46f3cb5ab
typ = ID
sid = yt52UejvYYsurMpluaCEvXXg
acr = 1
email_verified = true
name = Ada Lovelace
preferred_username = ada@bookstore.test
given_name = Ada
family_name = Lovelace
email = ada@bookstore.test
What the token says is what you get. With MapInboundClaims = false you also have to say which claim User.Identity.Name is read from; that is what the NameClaimType = "name" line is for. If you use roles, the same goes for RoleClaimType.
What is missing from the list is instructive too: iss, aud, exp, nonce, azp, at_hash. The handler's ClaimActions list deletes these by default; in the OpenIdConnectOptions constructor in the ASP.NET Core source, acr is on that list as well. They were needed for validation, but there is no point in them taking up space in the cookie. Step-up needs acr, so I removed the delete rule with ClaimActions.Remove("acr"); that is why acr = 1 stays in the output.
With GetClaimsFromUserInfoEndpoint = true, the handler also calls UserInfo after exchanging the code; the Trace-level log:
trce: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[30]
Token response received.
trce: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[20]
Retrieving claims from the user info endpoint.
trce: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[35]
User information received: System.Text.Json.JsonDocument
Careful: only the keys that have a mapping in ClaimActions make it from the UserInfo response into the principal. The default mappings are sub, name, given_name, family_name, profile and email; if you want another field, add it yourself, e.g. options.ClaimActions.MapUniqueJsonKey("preferred_username", "preferred_username"). The handler also checks that the sub from UserInfo equals the sub in the ID token and rejects the sign-in if they differ.
SaveTokens = true puts the access, refresh and ID tokens inside the authentication cookie; you read them with await HttpContext.GetTokenAsync("access_token"). The price is cookie size: the cookie in the previous post was split into two chunks. Keeping tokens on the server instead of in the cookie (ITicketStore from post 02) comes back in the BFF (Backend for Frontend) post.
Logout 1: RP-Initiated
The difficulty with logout is that there are two sessions: the app's cookie and Keycloak's SSO (Single Sign-On) session. If you delete only the app's cookie, when Ada clicks "Sign in" Keycloak sends her straight back without a password; that is the answer to the second question. RP (Relying Party)-initiated logout (OpenID Connect RP-Initiated Logout 1.0; RP, Relying Party, meaning the client) means signing out of both schemes:
app.MapPost("/logout", () => Results.SignOut(
new AuthenticationProperties { RedirectUri = "/signed-out" },
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
$ sh logout12.sh
callback 302 http://localhost:19212/claims
1) 302 http://localhost:19200/realms/bookstore/protocol/openid-connect/logout?post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A19212%2Fsignout-callback-oidc&id_token_hint=eyJ...&state=CfDJ8...&x-client-SKU=ID_NET10_0&x-client-ver=8.19.2.0
2) 302 http://localhost:19212/signout-callback-oidc?state=CfDJ8...
3) 302 http://localhost:19212/signed-out
4) Signed out.
In step one the cookie handler deletes the cookie and the OIDC handler sends the browser to the end_session_endpoint. id_token_hint tells Keycloak "I started this session, for this client"; without it Keycloak 26 shows a confirmation page. post_logout_redirect_uri must be registered on the client (post.logout.redirect.uris); the exact-match rule for redirect URIs applies here as well. In step two Keycloak ends the SSO session and sends the browser back, and the handler extracts the RedirectUri from state. I made logout a POST: another site can trigger a GET logout endpoint with an img tag. On a real page this form must be sent with an antiforgery token.
Logout 2: Front-Channel and Back-Channel
RP-initiated logout covers only the app that started it and Keycloak. If Ada entered three Bookstore apps with the same SSO session, the cookies of the other two stay alive. Keycloak has two ways to notify the other clients.
Front-channel logout (OpenID Connect Front-Channel Logout 1.0) does it through the browser: Keycloak's logout page opens each client's logout address in a hidden iframe, and the client deletes its own cookie. As browsers block third-party cookies, the client's cookie is not sent with the iframe request and this method silently stops working. That is why frontchannelLogout is off on the lab's clients.
Back-channel logout (OpenID Connect Back-Channel Logout 1.0) does not use the browser at all: Keycloak POSTs a signed logout_token server to server to each client's registered address. bookstore-web is registered with http://host.docker.internal:19212/backchannel-logout; the Keycloak container reaches the app under that name. The endpoint:
app.MapPost("/backchannel-logout", async (HttpRequest request, IOptionsMonitor<OpenIdConnectOptions> oidcOptions,
RevokedSessions revoked, ILogger<Program> logger) =>
{
IFormCollection form = await request.ReadFormAsync();
string? logoutToken = form["logout_token"];
if (string.IsNullOrEmpty(logoutToken))
{
return Results.BadRequest();
}
OpenIdConnectOptions options = oidcOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
OpenIdConnectConfiguration config = await options.ConfigurationManager!.GetConfigurationAsync(request.HttpContext.RequestAborted);
TokenValidationResult result = await new JsonWebTokenHandler().ValidateTokenAsync(logoutToken, new TokenValidationParameters
{
ValidIssuer = config.Issuer,
ValidAudience = options.ClientId,
IssuerSigningKeys = config.SigningKeys,
ValidTypes = ["logout+jwt"]
});
if (!result.IsValid)
{
logger.LogWarning("Rejected logout token: {Error}", result.Exception?.Message);
return Results.BadRequest();
}
// OIDC Back-Channel Logout 1.0, 2.6: events claim required, no nonce; sid required because we revoke by sid
string? sid = result.Claims.TryGetValue("sid", out object? sidValue) ? sidValue as string : null;
if (!result.Claims.TryGetValue("events", out object? events)
|| !events.ToString()!.Contains("http://schemas.openid.net/event/backchannel-logout")
|| result.Claims.ContainsKey("nonce")
|| sid is null)
{
logger.LogWarning("Rejected logout token: required claims missing");
return Results.BadRequest();
}
logger.LogDebug("Logout token claims: {Claims}", JsonSerializer.Serialize(result.Claims));
revoked.Add(sid);
logger.LogInformation("Back-channel logout for sid {Sid}", sid);
return Results.Ok();
}).DisableAntiforgery();
The logout token is validated with the same keys from discovery: the issuer is Keycloak, the audience is this client, the header's typ is logout+jwt. The "no nonce" rule prevents an ID token from being used as a logout token. In my first version I put the IsValid check in the same if as the claim checks; for an invalid token result.Claims came back empty, and an attempt that sent an ID token made the endpoint return 500. With validation as a separate step:
$ curl -s -o /dev/null -w "%{http_code}\n" -d "logout_token=$IDT" http://localhost:19212/backchannel-logout
400
warn: Program[0]
Rejected logout token: IDX10214: Audience validation failed. See https://aka.ms/identitymodel/app-context-switches
The endpoint's only job is to record which session ended; the cookies are in the browser, and the server cannot reach them. The actual revocation happens when the cookie is validated on each request:
.AddCookie(options =>
{
options.Events.OnValidatePrincipal = context =>
{
// Reject a cookie that belongs to an IdP session revoked by back-channel logout
RevokedSessions revoked = context.HttpContext.RequestServices.GetRequiredService<RevokedSessions>();
string? sid = context.Principal?.FindFirstValue("sid");
if (sid is not null && revoked.Contains(sid))
{
context.RejectPrincipal();
return context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
return Task.CompletedTask;
};
})
The scenario: Ada signs in to the web app, then signs in to bookstore-spa in the same browser (Keycloak issues a code without a password; SSO) and signs out of the SPA (Single-Page Application):
$ sh bclogout12.sh
callback 302 http://localhost:19212/claims
web /claims: 200
spa authorize (SSO, no password): http://localhost:19211/callback?state=s2&session_state=pvAV4...
spa logout: 302 http://localhost:19211/
web /claims: 302 http://localhost:19200/realms/bookstore/protocol/openid-connect/auth?client_id=booksto...
dbug: Program[0]
Logout token claims: {"exp":1794116412,"iat":1794116292,"jti":"a88d91b0-d963-f093-7e9c-13621f6df135","iss":"http://localhost:19200/realms/bookstore","aud":"bookstore-web","sub":"813c410a-8e19-4979-85aa-5cc46f3cb5ab","typ":"Logout","sid":"pvAV4sgJFPyvJR7cS1ZeyIQl","events":{"http://schemas.openid.net/event/backchannel-logout":{}}}
info: Program[0]
Back-channel logout for sid pvAV4sgJFPyvJR7cS1ZeyIQl
The web app's cookie is still in the browser, but on the next request OnValidatePrincipal rejected it and the user was sent back to Keycloak. The answer to the third question: with back-channel logout in place, that session ends too.
In Production
RevokedSessions lives in memory for the lab. With several instances the revocation list must be shared (a distributed cache like Redis), and entries must be kept at least as long as the cookie lifetime; keep them shorter and a revoked cookie becomes valid again the moment its entry drops off the list. The cleaner path is ITicketStore from post 02: if the session lives on the server, back-channel logout deletes that record directly.
The back-channel endpoint must be at an address the authorization server can reach; if the app sits on an internal network, talk to the network team about access from Keycloak. I tried the same scenario with the app stopped: Keycloak still completed the logout and returned 302, writing only a warning to its log:
WARN [org.keycloak.services] KC-SERVICES0057: Logout for client 'bookstore-web' failed: org.apache.http.conn.HttpHostConnectException: Connect to host.docker.internal:19212 [host.docker.internal/192.168.65.254] failed: Connection refused
No retry shows up in the log, and the client has no way to learn about an undelivered logout; the only defense against a missed logout is a short cookie lifetime and periodic revalidation. Watch this warning in your logs as an alert. Don't turn OnValidatePrincipal into heavy work on every request: a single lookup in memory or cache is enough.
The handler caches the discovery document (ConfigurationManager's default refresh interval is 12 hours); if Keycloak changes its key, the handler triggers a refresh itself on an unknown kid. That is why it matters that the authorization server keeps the old key in the JWKS for a while during rotation; the rule from post 08 applies unchanged.
Common Pitfalls
First, sending the ID token to an API. Above, even Keycloak's own UserInfo endpoint rejected it; if your API accepts ID tokens, its audience validation is wrong. Second, turning off MapInboundClaims in one handler and leaving it on in another; sub exists for the same user in one place and not in the other. Third, matching users by email; the right key is iss + sub.
Fourth, logging out with only SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme) and leaving the SSO session open; on a shared computer the next user clicks "Sign in" and gets in as the previous one. Fifth, making logout a GET endpoint. Sixth, reading sid in the back-channel endpoint without validating the logout token; then anyone can kick anyone out.
A Small Experiment
★☆☆ With MapInboundClaims = false, delete the NameClaimType line and print the value of User.Identity.Name. Explain why it is null by looking at the handler's default NameClaimType.
★★☆ In Keycloak, define a mapper that adds the amr claim to the ID token for bookstore-web, add a mapping to ClaimActions if needed, and see it in /claims. Run the step-up policy from post 05 with this claim.
★★★ Move RevokedSessions onto IDistributedCache, run the app as two instances on two ports, and show that a back-channel logout drops the cookie on both instances no matter which one receives it.
We have managed the user's identity and session end to end. In the next post we move to the API side: validating access tokens with AddJwtBearer, an audience per API, mapping scopes to policies, a DelegatingHandler that caches client credentials tokens, and DPoP, which makes a stolen bearer token useless.