Skip to content

Opaque (reference) access token guidance#36588

Open
guardrex wants to merge 14 commits into
mainfrom
guardrex/blazor-oidc-opaque-access-tokens
Open

Opaque (reference) access token guidance#36588
guardrex wants to merge 14 commits into
mainfrom
guardrex/blazor-oidc-opaque-access-tokens

Conversation

@guardrex
Copy link
Copy Markdown
Collaborator

@guardrex guardrex commented Jan 7, 2026

Fixes #36422

cc: @mikekistler

Stephen ... I hacked some nasty 🦖 code 🙈😆 with the help of AI to give you an idea of what I have in mind for the bits that call the auth server to validate the token.

Apparently, Entra doesn't support opaque access token validation, per this MS answer as of 2023 and a local test that I just ran here with Entra.

I originally had this in the BWA-OIDC article, but it's more general than that, so I just moved it to the additional scenarios article and cross-linked to it there from a few spots.


Internal previews

📄 File 🔗 Preview link
aspnetcore/blazor/security/additional-scenarios.md aspnetcore/blazor/security/additional-scenarios
aspnetcore/blazor/security/blazor-web-app-with-entra.md aspnetcore/blazor/security/blazor-web-app-with-entra
aspnetcore/blazor/security/blazor-web-app-with-oidc.md aspnetcore/blazor/security/blazor-web-app-with-oidc
aspnetcore/blazor/security/index.md aspnetcore/blazor/security/index

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive guidance for handling opaque (reference) access tokens in ASP.NET Core Blazor Web Apps with OIDC authentication, addressing issue #36422. The documentation explains when opaque tokens are supported by default and provides a starting-point implementation for scenarios requiring custom token validation.

Key Changes

  • Explains that AddOpenIdConnect inherently supports opaque tokens for basic authentication scenarios without additional configuration
  • Documents the limitation when opaque tokens need to be validated by services using AddJwtBearer
  • Provides a custom AuthenticationHandler implementation as a starting point for developers who need to validate opaque tokens via introspection endpoints

Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
@guardrex guardrex requested a review from halter73 January 7, 2026 14:33
@guardrex

This comment was marked as outdated.

@guardrex

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 6 comments.

Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
Comment thread aspnetcore/blazor/security/blazor-web-app-with-oidc.md Outdated
guardrex and others added 13 commits May 19, 2026 09:12
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Luke Latham <1622880+guardrex@users.noreply.github.com>
@guardrex guardrex force-pushed the guardrex/blazor-oidc-opaque-access-tokens branch from b071c5a to 8b16fd9 Compare May 19, 2026 13:14
Comment on lines +1190 to +1191
var introspectionUri = options.IntrospectionEndpoint;
var clientId = options.ClientId;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't compile. options is the IOptionsMonitor<OpaqueTokenAuthenticationOptions> constructor parameter, which doesn't have IntrospectionEndpoint/ClientId properties. Use the base class's Options property (capital O) instead, which exposes the resolved options for the current scheme:

Suggested change
var introspectionUri = options.IntrospectionEndpoint;
var clientId = options.ClientId;
var introspectionUri = Options.IntrospectionEndpoint;
var clientId = Options.ClientId;

If you keep this fix, the options constructor parameter is no longer referenced directly and the using Microsoft.Extensions.Options; line can stay (still needed for the generic constraint passed to the base type).

The preceding example's placeholders:

* `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI
* `{API CLIENT ID}`: API Client ID
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Sentence case per the style guide:

Suggested change
* `{API CLIENT ID}`: API Client ID
* `{API CLIENT ID}`: API client ID


var introspectionUri = options.IntrospectionEndpoint;
var clientId = options.ClientId;
var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scheme name in this config key (AuthServer) doesn't match OpaqueTokenAuthenticationOptions.DefaultScheme ("OpaqueTokenAuthentication"). The standard Authentication:Schemes:{SchemeName}:... convention would make this:

Suggested change
var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"];
var clientSecret = config["Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret"];

If you change this, also update the dotnet user-secrets set command on line 1095 and the explanatory text on line 1084 to use the same key.

Comment on lines +1135 to +1140
public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "OpaqueTokenAuthentication";
public string? IntrospectionEndpoint { get; set; }
public string? ClientId { get; set; }
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the Authentication:Schemes:AuthServer:ClientSecret comment further down: the cleaner pattern is to add ClientSecret to OpaqueTokenAuthenticationOptions and let the framework's normal Authentication:Schemes:{SchemeName}:* binding pick it up. Then the handler doesn't need an IConfiguration dependency at all and the config key in the docs and the dotnet user-secrets set command would automatically match the scheme name. Optional, but worth considering since it eliminates the naming-inconsistency surface area entirely.

Suggested change
public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "OpaqueTokenAuthentication";
public string? IntrospectionEndpoint { get; set; }
public string? ClientId { get; set; }
}
public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "OpaqueTokenAuthentication";
public string? IntrospectionEndpoint { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
}

If you apply this, also:

  • Drop the IConfiguration config parameter from the handler's constructor.
  • Replace config["Authentication:Schemes:AuthServer:ClientSecret"] with Options.ClientSecret.
  • Update the dotnet user-secrets set command to use Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret.

Comment on lines +1230 to +1232
// TODO: Replace the '{USER ID}' placeholder with extracted claim value
// from the token introspection response
var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most important issue with the section. I stood up Keycloak and ran the sample end-to-end with a real access token. With the handler as written, the call succeeds (IsAuthenticated == true) but produces a ClaimsPrincipal with Name = "{USER ID}", no NameIdentifier, no email, and no roles. The // TODO is buried inside a code block and very easy to miss — a reader who copies this verbatim ships authentication that looks like it works but exposes no usable user information.

Please replace the placeholder with a real mapping from the standard RFC 7662 introspection fields. Suggested:

Suggested change
// TODO: Replace the '{USER ID}' placeholder with extracted claim value
// from the token introspection response
var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") };
// Map standard introspection response fields onto claims.
// Field names below match what Keycloak, Duende IdentityServer,
// Auth0, and Okta return; adjust the role source for your provider.
var claims = new List<Claim>();
string? Get(string name) =>
doc.RootElement.TryGetProperty(name, out var v) &&
v.ValueKind == JsonValueKind.String ? v.GetString() : null;
var sub = Get("sub");
var username = Get("preferred_username") ?? Get("username") ?? sub;
if (sub is not null) claims.Add(new Claim(ClaimTypes.NameIdentifier, sub));
if (username is not null) claims.Add(new Claim(ClaimTypes.Name, username));
if (Get("email") is { } email) claims.Add(new Claim(ClaimTypes.Email, email));
if ((Get("client_id") ?? Get("azp")) is { } cid)
claims.Add(new Claim("client_id", cid));
if (Get("scope") is { } scope)
foreach (var s in scope.Split(' ', StringSplitOptions.RemoveEmptyEntries))
claims.Add(new Claim("scope", s));
// Keycloak surfaces realm roles under realm_access.roles.
// Duende/IdentityServer uses a flat "role" claim; Auth0 uses a
// configurable custom claim. Adjust for your authorization server.
if (doc.RootElement.TryGetProperty("realm_access", out var ra) &&
ra.ValueKind == JsonValueKind.Object &&
ra.TryGetProperty("roles", out var roles) &&
roles.ValueKind == JsonValueKind.Array)
{
foreach (var r in roles.EnumerateArray())
if (r.ValueKind == JsonValueKind.String)
claims.Add(new Claim(ClaimTypes.Role, r.GetString()!));
}
var identity = new ClaimsIdentity(claims,
OpaqueTokenAuthenticationOptions.DefaultScheme,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);

Side-by-side test result against the same Keycloak token (user alice in roles reader, writer):

Doc handler (verbatim) With the mapping above
User.Identity.Name "{USER ID}" "alice"
User.IsInRole("reader") false true
Claim count 1 9


## Opaque (reference) access token support

*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on testing the sample against Keycloak:

  1. Keycloak and Okta also issue JWT access tokens by default — there isn't a first-class "opaque" toggle. The handler still works against them (introspection doesn't care whether the token is opaque or JWT), but readers reaching for this section because they're using Keycloak/Okta should know that "opaque" here refers to how the client treats the token, not how the server mints it. Duende IdentityServer is the main implementation in the .NET ecosystem that issues truly opaque reference tokens out of the box.

  2. Keycloak-specific footgun, worth a sentence: when testing introspection against Keycloak, the API's introspection client must be different from the OIDC client that issued the user's access token. If they're the same client, Keycloak returns {"active": false} with "Access token JWT check failed" in the server log, and the reader will think the handler is broken. In the documented Blazor Web App scenario this doesn't happen naturally (BlazorWebAppOidc and MinimalApiJwt are separate clients), but a one-liner saves the next person an hour.

Also, italicized prose for a caveat reads a bit awkwardly here. Consider a NOTE block so it stands out the same way the IMPORTANT block below does::

Suggested change
*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.*
> [!NOTE]
> The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation. Keycloak and Okta issue JWT access tokens by default; the handler still works against them because it relies only on RFC 7662 introspection, but "opaque" in this section describes how the client treats the token rather than how the server mints it. Duende IdentityServer issues true opaque reference tokens out of the box.
>
> When testing this pattern against Keycloak specifically, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OIDC Solution Doesn't Work With Opaque Access Tokens

3 participants