How do I add a second JWT bearer authentication scheme to an ASP.NET Core API without it conflicting with the existing one?
howto · dotnet · aspnet-core · security · authentication · entra-id · jwt · oauth
Register each handler under its own scheme name, `AddJwtBearer("Legacy", ...)` and `AddMicrosoftIdentityWebApi(config, jwtBearerScheme: "EntraId")`; two unnamed ones both default to `"Bearer"` and throw on startup. Pick a scheme per request with `[Authorize(AuthenticationSchemes = "Legacy,EntraId")]`, or automatically via `AddPolicyScheme`'s `ForwardDefaultSelector`, reading the token's unvalidated `iss` claim. Either way each scheme still fully validates the token itself; the selector only routes, it never authenticates.
Why registering the second scheme the obvious way fails
Section titled “Why registering the second scheme the obvious way fails”AddJwtBearer() with no scheme name argument, and AddMicrosoftIdentityWebApi(config) with no
jwtBearerScheme argument, both register the handler under JwtBearerDefaults.AuthenticationScheme,
the literal string "Bearer". That is fine for a single scheme, which is why the existing entry on
validating Entra ID tokens never names one. Calling
either method a second time without a name tries to register a second handler under the same
"Bearer" name and throws InvalidOperationException: Scheme already exists: Bearer at startup, not
at request time - the two schemes never even coexist long enough to conflict on a real request. The
fix is to name both explicitly and never let either fall back to the default:
static void Register(WebApplicationBuilder builder){ builder.Services.AddAuthentication() // the existing scheme, unchanged apart from the explicit name .AddJwtBearer("Legacy", options => { options.Authority = builder.Configuration["Legacy:Authority"]; options.Audience = builder.Configuration["Legacy:Audience"]; }) // Entra ID, registered under its own name instead of "Bearer" .AddMicrosoftIdentityWebApi( builder.Configuration.GetSection("AzureAd"), jwtBearerScheme: "EntraId");}compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.Identity.Web@4.15.0#:package xunit.assert@2.9.3
// Two JWT bearer schemes, the policy scheme that routes between them, and// the trap that makes the routing safe: the selector reads an unvalidated// claim, so whichever handler it forwards to still checks signature,// issuer, audience and expiry on its own. AddMicrosoftIdentityWebApi needs// a real Entra ID tenant to fetch signing keys from, which a headless run// has none of, so the registration region below - the exact call the entry// shows - is compiled but never invoked, the same way the interactive// sign-in calls of jwt-authentication-desktop-client.cs are; the "EntraId"// scheme every claim below actually runs against is a second AddJwtBearer,// validating against a local key instead of Entra's, which is what// Microsoft.Identity.Web's wrapper amounts to once the discovery document// is behind it.using System.Net;using System.Net.Http.Headers;using System.Security.Cryptography;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.Identity.Web;using Microsoft.Extensions.Options;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;using Microsoft.Net.Http.Headers;using Xunit;
await Claims.NamingBothSchemesAvoidsTheStartupThrow();await Claims.DefaultPolicyAcceptsEitherScheme();await Claims.PerEndpointSchemeIgnoresTheOtherToken();await Claims.SelectorRoutesOnTheUnvalidatedIssuer();await Claims.SelectorIsOnlyARoutingHint();Console.WriteLine("multiple-jwt-bearer-schemes: all claims hold");
static class Claims{ // AddJwtBearer() with no name, called twice, both default to "Bearer": // the second AddScheme call throws once the options it queued are // actually resolved. public static Task NamingBothSchemesAvoidsTheStartupThrow() { var services = new ServiceCollection(); services.AddAuthentication().AddJwtBearer().AddJwtBearer(); var provider = services.BuildServiceProvider();
var ex = Assert.Throws<InvalidOperationException>( () => provider.GetRequiredService<IOptions<AuthenticationOptions>>().Value); Assert.Contains("Bearer", ex.Message); return Task.CompletedTask; }
public static async Task DefaultPolicyAcceptsEitherScheme() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); UseBothSchemesByDefault(builder.Services); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok())); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); }
public static async Task PerEndpointSchemeIgnoresTheOtherToken() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); builder.Services.AddAuthorization(); }, MapEndpoints); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/legacy-only", Tokens.LegacyToken())); // A valid Entra ID token is ignored here, not merely insufficient: // the same token succeeds at /either right below. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only"));
Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.EntraToken())); }
public static async Task SelectorRoutesOnTheUnvalidatedIssuer() { await using var app = RoutedApp(); var client = app.GetTestClient();
// A token this app never routes to the wrong handler proves the // selector chose correctly: a Legacy token only ever validates // against Legacy's key and issuer, an Entra one only against // EntraId's, so either succeeding at all shows it reached the // handler that can actually accept it. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); }
public static async Task SelectorIsOnlyARoutingHint() { await using var app = RoutedApp(); var client = app.GetTestClient();
// Same shape as a real Entra token - issuer and audience Entra // would issue - but signed with the Legacy scheme's key: what an // attacker who edits only the unvalidated iss claim of an // otherwise-useless token can produce, having no way to sign with // Entra's real key. The selector still reads it as an Entra // issuer and forwards it there, and it is still rejected, on // signature, exactly as any other forged token would be. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", Tokens.ForgedEntraLookingToken()));
// Not a JWT at all: CanReadToken is false, so the selector falls // through to Legacy without ever reading a claim, and Legacy's own // validation - not an unhandled exception - is what turns it into // a 401. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", "not-a-jwt-at-all")); }
static WebApplication RoutedApp() => BuildApp( builder => { var auth = builder.Services.AddAuthentication(PolicyScheme) .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation); AddRoutingScheme(auth); builder.Services.AddAuthorization(); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok()));
static void RegisterLocalSchemes(WebApplicationBuilder builder) => builder.Services.AddAuthentication() .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation);
static WebApplication BuildApp(Action<WebApplicationBuilder> configureAuth, Action<WebApplication> mapEndpoints) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); configureAuth(builder); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); mapEndpoints(app); app.StartAsync().GetAwaiter().GetResult(); return app; }
static async Task<HttpStatusCode> Get(HttpClient client, string path, string? token = null) { var request = new HttpRequestMessage(HttpMethod.Get, path); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return (await client.SendAsync(request)).StatusCode; }
static void Register(WebApplicationBuilder builder) { builder.Services.AddAuthentication() // the existing scheme, unchanged apart from the explicit name .AddJwtBearer("Legacy", options => { options.Authority = builder.Configuration["Legacy:Authority"]; options.Audience = builder.Configuration["Legacy:Audience"]; }) // Entra ID, registered under its own name instead of "Bearer" .AddMicrosoftIdentityWebApi( builder.Configuration.GetSection("AzureAd"), jwtBearerScheme: "EntraId"); }
static void UseBothSchemesByDefault(IServiceCollection services) { var bothSchemes = new AuthorizationPolicyBuilder("Legacy", "EntraId") .RequireAuthenticatedUser() .Build();
services.AddAuthorizationBuilder() .SetDefaultPolicy(bothSchemes); }
static void MapEndpoints(WebApplication app) { // only the legacy scheme runs here; an Entra ID token is ignored, not merely insufficient app.MapGet("/legacy-only", [Authorize(AuthenticationSchemes = "Legacy")] () => Results.Ok()) .WithName("LegacyOnly");
// both handlers run; either a valid Legacy or a valid EntraId token succeeds app.MapGet("/either", [Authorize(AuthenticationSchemes = "Legacy,EntraId")] () => Results.Ok()) .WithName("Either"); }
const string PolicyScheme = "LegacyOrEntraId";
static void AddRoutingScheme(AuthenticationBuilder auth) => auth.AddPolicyScheme(PolicyScheme, displayName: null, options => { options.ForwardDefaultSelector = context => { string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) return "Legacy"; // no bearer token at all: let Legacy's handler produce the 401
var token = authorization["Bearer ".Length..].Trim(); var handler = new JsonWebTokenHandler(); if (handler.CanReadToken(token)) { var issuer = handler.ReadJsonWebToken(token).Issuer; if (issuer.StartsWith("https://login.microsoftonline.com/")) return "EntraId"; } return "Legacy"; }; });}
static class Tokens{ const string LegacyIssuer = "https://legacy.example.com"; const string LegacyAudience = "api://legacy"; const string EntraIssuer = "https://login.microsoftonline.com/test-tenant/v2.0"; const string EntraAudience = "api://entra";
static readonly SigningCredentials LegacyCredentials = NewCredentials(); static readonly SigningCredentials EntraCredentials = NewCredentials();
public static readonly TokenValidationParameters LegacyValidation = ValidationFor(LegacyIssuer, LegacyAudience, LegacyCredentials); public static readonly TokenValidationParameters EntraValidation = ValidationFor(EntraIssuer, EntraAudience, EntraCredentials);
static SigningCredentials NewCredentials() => new(new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)), SecurityAlgorithms.HmacSha256);
static TokenValidationParameters ValidationFor(string issuer, string audience, SigningCredentials credentials) => new() { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateIssuerSigningKey = true, IssuerSigningKey = credentials.Key, ValidateLifetime = true, };
static string Create(string issuer, string audience, SigningCredentials credentials) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = issuer, Audience = audience, SigningCredentials = credentials, NotBefore = DateTime.UtcNow.AddMinutes(-1), Expires = DateTime.UtcNow.AddMinutes(5), });
public static string LegacyToken() => Create(LegacyIssuer, LegacyAudience, LegacyCredentials); public static string EntraToken() => Create(EntraIssuer, EntraAudience, EntraCredentials); public static string ForgedEntraLookingToken() => Create(EntraIssuer, EntraAudience, LegacyCredentials);}AddAuthentication() here is called with no default scheme, which matters: passing one (as the
single-scheme entry does, AddAuthentication(JwtBearerDefaults.AuthenticationScheme)) sets
HttpContext.User from that one scheme alone before authorization even runs, silently ignoring
whichever scheme wasn’t picked as default. With two schemes meant to work independently, leave the
default scheme unset here and choose it in the authorization policy or per endpoint instead, below.
Making [Authorize] alone accept either token
Section titled “Making [Authorize] alone accept either token”An [Authorize] attribute with no scheme named runs the default authorization policy, which by
default requires an authenticated user with no scheme restriction - but only once that policy knows
about both scheme names. Build it explicitly instead of leaving it to infer a single default scheme:
static void UseBothSchemesByDefault(IServiceCollection services){ var bothSchemes = new AuthorizationPolicyBuilder("Legacy", "EntraId") .RequireAuthenticatedUser() .Build();
services.AddAuthorizationBuilder() .SetDefaultPolicy(bothSchemes);}compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.Identity.Web@4.15.0#:package xunit.assert@2.9.3
// Two JWT bearer schemes, the policy scheme that routes between them, and// the trap that makes the routing safe: the selector reads an unvalidated// claim, so whichever handler it forwards to still checks signature,// issuer, audience and expiry on its own. AddMicrosoftIdentityWebApi needs// a real Entra ID tenant to fetch signing keys from, which a headless run// has none of, so the registration region below - the exact call the entry// shows - is compiled but never invoked, the same way the interactive// sign-in calls of jwt-authentication-desktop-client.cs are; the "EntraId"// scheme every claim below actually runs against is a second AddJwtBearer,// validating against a local key instead of Entra's, which is what// Microsoft.Identity.Web's wrapper amounts to once the discovery document// is behind it.using System.Net;using System.Net.Http.Headers;using System.Security.Cryptography;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.Identity.Web;using Microsoft.Extensions.Options;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;using Microsoft.Net.Http.Headers;using Xunit;
await Claims.NamingBothSchemesAvoidsTheStartupThrow();await Claims.DefaultPolicyAcceptsEitherScheme();await Claims.PerEndpointSchemeIgnoresTheOtherToken();await Claims.SelectorRoutesOnTheUnvalidatedIssuer();await Claims.SelectorIsOnlyARoutingHint();Console.WriteLine("multiple-jwt-bearer-schemes: all claims hold");
static class Claims{ // AddJwtBearer() with no name, called twice, both default to "Bearer": // the second AddScheme call throws once the options it queued are // actually resolved. public static Task NamingBothSchemesAvoidsTheStartupThrow() { var services = new ServiceCollection(); services.AddAuthentication().AddJwtBearer().AddJwtBearer(); var provider = services.BuildServiceProvider();
var ex = Assert.Throws<InvalidOperationException>( () => provider.GetRequiredService<IOptions<AuthenticationOptions>>().Value); Assert.Contains("Bearer", ex.Message); return Task.CompletedTask; }
public static async Task DefaultPolicyAcceptsEitherScheme() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); UseBothSchemesByDefault(builder.Services); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok())); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); }
public static async Task PerEndpointSchemeIgnoresTheOtherToken() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); builder.Services.AddAuthorization(); }, MapEndpoints); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/legacy-only", Tokens.LegacyToken())); // A valid Entra ID token is ignored here, not merely insufficient: // the same token succeeds at /either right below. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only"));
Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.EntraToken())); }
public static async Task SelectorRoutesOnTheUnvalidatedIssuer() { await using var app = RoutedApp(); var client = app.GetTestClient();
// A token this app never routes to the wrong handler proves the // selector chose correctly: a Legacy token only ever validates // against Legacy's key and issuer, an Entra one only against // EntraId's, so either succeeding at all shows it reached the // handler that can actually accept it. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); }
public static async Task SelectorIsOnlyARoutingHint() { await using var app = RoutedApp(); var client = app.GetTestClient();
// Same shape as a real Entra token - issuer and audience Entra // would issue - but signed with the Legacy scheme's key: what an // attacker who edits only the unvalidated iss claim of an // otherwise-useless token can produce, having no way to sign with // Entra's real key. The selector still reads it as an Entra // issuer and forwards it there, and it is still rejected, on // signature, exactly as any other forged token would be. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", Tokens.ForgedEntraLookingToken()));
// Not a JWT at all: CanReadToken is false, so the selector falls // through to Legacy without ever reading a claim, and Legacy's own // validation - not an unhandled exception - is what turns it into // a 401. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", "not-a-jwt-at-all")); }
static WebApplication RoutedApp() => BuildApp( builder => { var auth = builder.Services.AddAuthentication(PolicyScheme) .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation); AddRoutingScheme(auth); builder.Services.AddAuthorization(); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok()));
static void RegisterLocalSchemes(WebApplicationBuilder builder) => builder.Services.AddAuthentication() .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation);
static WebApplication BuildApp(Action<WebApplicationBuilder> configureAuth, Action<WebApplication> mapEndpoints) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); configureAuth(builder); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); mapEndpoints(app); app.StartAsync().GetAwaiter().GetResult(); return app; }
static async Task<HttpStatusCode> Get(HttpClient client, string path, string? token = null) { var request = new HttpRequestMessage(HttpMethod.Get, path); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return (await client.SendAsync(request)).StatusCode; }
static void Register(WebApplicationBuilder builder) { builder.Services.AddAuthentication() // the existing scheme, unchanged apart from the explicit name .AddJwtBearer("Legacy", options => { options.Authority = builder.Configuration["Legacy:Authority"]; options.Audience = builder.Configuration["Legacy:Audience"]; }) // Entra ID, registered under its own name instead of "Bearer" .AddMicrosoftIdentityWebApi( builder.Configuration.GetSection("AzureAd"), jwtBearerScheme: "EntraId"); }
static void UseBothSchemesByDefault(IServiceCollection services) { var bothSchemes = new AuthorizationPolicyBuilder("Legacy", "EntraId") .RequireAuthenticatedUser() .Build();
services.AddAuthorizationBuilder() .SetDefaultPolicy(bothSchemes); }
static void MapEndpoints(WebApplication app) { // only the legacy scheme runs here; an Entra ID token is ignored, not merely insufficient app.MapGet("/legacy-only", [Authorize(AuthenticationSchemes = "Legacy")] () => Results.Ok()) .WithName("LegacyOnly");
// both handlers run; either a valid Legacy or a valid EntraId token succeeds app.MapGet("/either", [Authorize(AuthenticationSchemes = "Legacy,EntraId")] () => Results.Ok()) .WithName("Either"); }
const string PolicyScheme = "LegacyOrEntraId";
static void AddRoutingScheme(AuthenticationBuilder auth) => auth.AddPolicyScheme(PolicyScheme, displayName: null, options => { options.ForwardDefaultSelector = context => { string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) return "Legacy"; // no bearer token at all: let Legacy's handler produce the 401
var token = authorization["Bearer ".Length..].Trim(); var handler = new JsonWebTokenHandler(); if (handler.CanReadToken(token)) { var issuer = handler.ReadJsonWebToken(token).Issuer; if (issuer.StartsWith("https://login.microsoftonline.com/")) return "EntraId"; } return "Legacy"; }; });}
static class Tokens{ const string LegacyIssuer = "https://legacy.example.com"; const string LegacyAudience = "api://legacy"; const string EntraIssuer = "https://login.microsoftonline.com/test-tenant/v2.0"; const string EntraAudience = "api://entra";
static readonly SigningCredentials LegacyCredentials = NewCredentials(); static readonly SigningCredentials EntraCredentials = NewCredentials();
public static readonly TokenValidationParameters LegacyValidation = ValidationFor(LegacyIssuer, LegacyAudience, LegacyCredentials); public static readonly TokenValidationParameters EntraValidation = ValidationFor(EntraIssuer, EntraAudience, EntraCredentials);
static SigningCredentials NewCredentials() => new(new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)), SecurityAlgorithms.HmacSha256);
static TokenValidationParameters ValidationFor(string issuer, string audience, SigningCredentials credentials) => new() { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateIssuerSigningKey = true, IssuerSigningKey = credentials.Key, ValidateLifetime = true, };
static string Create(string issuer, string audience, SigningCredentials credentials) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = issuer, Audience = audience, SigningCredentials = credentials, NotBefore = DateTime.UtcNow.AddMinutes(-1), Expires = DateTime.UtcNow.AddMinutes(5), });
public static string LegacyToken() => Create(LegacyIssuer, LegacyAudience, LegacyCredentials); public static string EntraToken() => Create(EntraIssuer, EntraAudience, EntraCredentials); public static string ForgedEntraLookingToken() => Create(EntraIssuer, EntraAudience, LegacyCredentials);}With that in place, [Authorize] on any endpoint runs both handlers and accepts whichever one
produces a valid principal; a request with neither a legacy token nor an Entra ID token still gets a
plain 401, from whichever handler ran last.
Picking one scheme (or a union) per endpoint
Section titled “Picking one scheme (or a union) per endpoint”An endpoint that must accept only one kind of caller names that scheme instead of relying on the default policy:
static void MapEndpoints(WebApplication app){ // only the legacy scheme runs here; an Entra ID token is ignored, not merely insufficient app.MapGet("/legacy-only", [Authorize(AuthenticationSchemes = "Legacy")] () => Results.Ok()) .WithName("LegacyOnly");
// both handlers run; either a valid Legacy or a valid EntraId token succeeds app.MapGet("/either", [Authorize(AuthenticationSchemes = "Legacy,EntraId")] () => Results.Ok()) .WithName("Either");}compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.Identity.Web@4.15.0#:package xunit.assert@2.9.3
// Two JWT bearer schemes, the policy scheme that routes between them, and// the trap that makes the routing safe: the selector reads an unvalidated// claim, so whichever handler it forwards to still checks signature,// issuer, audience and expiry on its own. AddMicrosoftIdentityWebApi needs// a real Entra ID tenant to fetch signing keys from, which a headless run// has none of, so the registration region below - the exact call the entry// shows - is compiled but never invoked, the same way the interactive// sign-in calls of jwt-authentication-desktop-client.cs are; the "EntraId"// scheme every claim below actually runs against is a second AddJwtBearer,// validating against a local key instead of Entra's, which is what// Microsoft.Identity.Web's wrapper amounts to once the discovery document// is behind it.using System.Net;using System.Net.Http.Headers;using System.Security.Cryptography;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.Identity.Web;using Microsoft.Extensions.Options;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;using Microsoft.Net.Http.Headers;using Xunit;
await Claims.NamingBothSchemesAvoidsTheStartupThrow();await Claims.DefaultPolicyAcceptsEitherScheme();await Claims.PerEndpointSchemeIgnoresTheOtherToken();await Claims.SelectorRoutesOnTheUnvalidatedIssuer();await Claims.SelectorIsOnlyARoutingHint();Console.WriteLine("multiple-jwt-bearer-schemes: all claims hold");
static class Claims{ // AddJwtBearer() with no name, called twice, both default to "Bearer": // the second AddScheme call throws once the options it queued are // actually resolved. public static Task NamingBothSchemesAvoidsTheStartupThrow() { var services = new ServiceCollection(); services.AddAuthentication().AddJwtBearer().AddJwtBearer(); var provider = services.BuildServiceProvider();
var ex = Assert.Throws<InvalidOperationException>( () => provider.GetRequiredService<IOptions<AuthenticationOptions>>().Value); Assert.Contains("Bearer", ex.Message); return Task.CompletedTask; }
public static async Task DefaultPolicyAcceptsEitherScheme() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); UseBothSchemesByDefault(builder.Services); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok())); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); }
public static async Task PerEndpointSchemeIgnoresTheOtherToken() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); builder.Services.AddAuthorization(); }, MapEndpoints); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/legacy-only", Tokens.LegacyToken())); // A valid Entra ID token is ignored here, not merely insufficient: // the same token succeeds at /either right below. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only"));
Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.EntraToken())); }
public static async Task SelectorRoutesOnTheUnvalidatedIssuer() { await using var app = RoutedApp(); var client = app.GetTestClient();
// A token this app never routes to the wrong handler proves the // selector chose correctly: a Legacy token only ever validates // against Legacy's key and issuer, an Entra one only against // EntraId's, so either succeeding at all shows it reached the // handler that can actually accept it. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); }
public static async Task SelectorIsOnlyARoutingHint() { await using var app = RoutedApp(); var client = app.GetTestClient();
// Same shape as a real Entra token - issuer and audience Entra // would issue - but signed with the Legacy scheme's key: what an // attacker who edits only the unvalidated iss claim of an // otherwise-useless token can produce, having no way to sign with // Entra's real key. The selector still reads it as an Entra // issuer and forwards it there, and it is still rejected, on // signature, exactly as any other forged token would be. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", Tokens.ForgedEntraLookingToken()));
// Not a JWT at all: CanReadToken is false, so the selector falls // through to Legacy without ever reading a claim, and Legacy's own // validation - not an unhandled exception - is what turns it into // a 401. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", "not-a-jwt-at-all")); }
static WebApplication RoutedApp() => BuildApp( builder => { var auth = builder.Services.AddAuthentication(PolicyScheme) .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation); AddRoutingScheme(auth); builder.Services.AddAuthorization(); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok()));
static void RegisterLocalSchemes(WebApplicationBuilder builder) => builder.Services.AddAuthentication() .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation);
static WebApplication BuildApp(Action<WebApplicationBuilder> configureAuth, Action<WebApplication> mapEndpoints) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); configureAuth(builder); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); mapEndpoints(app); app.StartAsync().GetAwaiter().GetResult(); return app; }
static async Task<HttpStatusCode> Get(HttpClient client, string path, string? token = null) { var request = new HttpRequestMessage(HttpMethod.Get, path); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return (await client.SendAsync(request)).StatusCode; }
static void Register(WebApplicationBuilder builder) { builder.Services.AddAuthentication() // the existing scheme, unchanged apart from the explicit name .AddJwtBearer("Legacy", options => { options.Authority = builder.Configuration["Legacy:Authority"]; options.Audience = builder.Configuration["Legacy:Audience"]; }) // Entra ID, registered under its own name instead of "Bearer" .AddMicrosoftIdentityWebApi( builder.Configuration.GetSection("AzureAd"), jwtBearerScheme: "EntraId"); }
static void UseBothSchemesByDefault(IServiceCollection services) { var bothSchemes = new AuthorizationPolicyBuilder("Legacy", "EntraId") .RequireAuthenticatedUser() .Build();
services.AddAuthorizationBuilder() .SetDefaultPolicy(bothSchemes); }
static void MapEndpoints(WebApplication app) { // only the legacy scheme runs here; an Entra ID token is ignored, not merely insufficient app.MapGet("/legacy-only", [Authorize(AuthenticationSchemes = "Legacy")] () => Results.Ok()) .WithName("LegacyOnly");
// both handlers run; either a valid Legacy or a valid EntraId token succeeds app.MapGet("/either", [Authorize(AuthenticationSchemes = "Legacy,EntraId")] () => Results.Ok()) .WithName("Either"); }
const string PolicyScheme = "LegacyOrEntraId";
static void AddRoutingScheme(AuthenticationBuilder auth) => auth.AddPolicyScheme(PolicyScheme, displayName: null, options => { options.ForwardDefaultSelector = context => { string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) return "Legacy"; // no bearer token at all: let Legacy's handler produce the 401
var token = authorization["Bearer ".Length..].Trim(); var handler = new JsonWebTokenHandler(); if (handler.CanReadToken(token)) { var issuer = handler.ReadJsonWebToken(token).Issuer; if (issuer.StartsWith("https://login.microsoftonline.com/")) return "EntraId"; } return "Legacy"; }; });}
static class Tokens{ const string LegacyIssuer = "https://legacy.example.com"; const string LegacyAudience = "api://legacy"; const string EntraIssuer = "https://login.microsoftonline.com/test-tenant/v2.0"; const string EntraAudience = "api://entra";
static readonly SigningCredentials LegacyCredentials = NewCredentials(); static readonly SigningCredentials EntraCredentials = NewCredentials();
public static readonly TokenValidationParameters LegacyValidation = ValidationFor(LegacyIssuer, LegacyAudience, LegacyCredentials); public static readonly TokenValidationParameters EntraValidation = ValidationFor(EntraIssuer, EntraAudience, EntraCredentials);
static SigningCredentials NewCredentials() => new(new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)), SecurityAlgorithms.HmacSha256);
static TokenValidationParameters ValidationFor(string issuer, string audience, SigningCredentials credentials) => new() { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateIssuerSigningKey = true, IssuerSigningKey = credentials.Key, ValidateLifetime = true, };
static string Create(string issuer, string audience, SigningCredentials credentials) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = issuer, Audience = audience, SigningCredentials = credentials, NotBefore = DateTime.UtcNow.AddMinutes(-1), Expires = DateTime.UtcNow.AddMinutes(5), });
public static string LegacyToken() => Create(LegacyIssuer, LegacyAudience, LegacyCredentials); public static string EntraToken() => Create(EntraIssuer, EntraAudience, EntraCredentials); public static string ForgedEntraLookingToken() => Create(EntraIssuer, EntraAudience, LegacyCredentials);}The comma-delimited list is a union: authorization succeeds if any named scheme authenticates the
request, and if more than one does (a caller somehow presenting both a valid cookie and a valid
bearer token, for instance) the resulting claims are merged into one ClaimsPrincipal. Naming a
single scheme instead of relying on [Authorize] alone is how a specific controller or minimal API
group is pinned to, say, Entra ID only while the rest of the API still accepts either.
Routing automatically instead of naming both schemes everywhere
Section titled “Routing automatically instead of naming both schemes everywhere”Naming "Legacy,EntraId" on every endpoint (or relying on the default policy above) works when both
kinds of caller are meant to reach the same endpoints. A caller that should never even attempt the
other scheme’s handshake - so a bad legacy token isn’t retried against Entra ID’s discovery endpoint,
or the other way round - is better served by picking the scheme once, before either handler runs,
with AddPolicyScheme, registered on the same builder as the two schemes above and made the default in
place of leaving it unset:
const string PolicyScheme = "LegacyOrEntraId";
static void AddRoutingScheme(AuthenticationBuilder auth) => auth.AddPolicyScheme(PolicyScheme, displayName: null, options => { options.ForwardDefaultSelector = context => { string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) return "Legacy"; // no bearer token at all: let Legacy's handler produce the 401
var token = authorization["Bearer ".Length..].Trim(); var handler = new JsonWebTokenHandler(); if (handler.CanReadToken(token)) { var issuer = handler.ReadJsonWebToken(token).Issuer; if (issuer.StartsWith("https://login.microsoftonline.com/")) return "EntraId"; } return "Legacy"; }; });compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.Identity.Web@4.15.0#:package xunit.assert@2.9.3
// Two JWT bearer schemes, the policy scheme that routes between them, and// the trap that makes the routing safe: the selector reads an unvalidated// claim, so whichever handler it forwards to still checks signature,// issuer, audience and expiry on its own. AddMicrosoftIdentityWebApi needs// a real Entra ID tenant to fetch signing keys from, which a headless run// has none of, so the registration region below - the exact call the entry// shows - is compiled but never invoked, the same way the interactive// sign-in calls of jwt-authentication-desktop-client.cs are; the "EntraId"// scheme every claim below actually runs against is a second AddJwtBearer,// validating against a local key instead of Entra's, which is what// Microsoft.Identity.Web's wrapper amounts to once the discovery document// is behind it.using System.Net;using System.Net.Http.Headers;using System.Security.Cryptography;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.Identity.Web;using Microsoft.Extensions.Options;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;using Microsoft.Net.Http.Headers;using Xunit;
await Claims.NamingBothSchemesAvoidsTheStartupThrow();await Claims.DefaultPolicyAcceptsEitherScheme();await Claims.PerEndpointSchemeIgnoresTheOtherToken();await Claims.SelectorRoutesOnTheUnvalidatedIssuer();await Claims.SelectorIsOnlyARoutingHint();Console.WriteLine("multiple-jwt-bearer-schemes: all claims hold");
static class Claims{ // AddJwtBearer() with no name, called twice, both default to "Bearer": // the second AddScheme call throws once the options it queued are // actually resolved. public static Task NamingBothSchemesAvoidsTheStartupThrow() { var services = new ServiceCollection(); services.AddAuthentication().AddJwtBearer().AddJwtBearer(); var provider = services.BuildServiceProvider();
var ex = Assert.Throws<InvalidOperationException>( () => provider.GetRequiredService<IOptions<AuthenticationOptions>>().Value); Assert.Contains("Bearer", ex.Message); return Task.CompletedTask; }
public static async Task DefaultPolicyAcceptsEitherScheme() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); UseBothSchemesByDefault(builder.Services); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok())); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); }
public static async Task PerEndpointSchemeIgnoresTheOtherToken() { await using var app = BuildApp( builder => { RegisterLocalSchemes(builder); builder.Services.AddAuthorization(); }, MapEndpoints); var client = app.GetTestClient();
Assert.Equal(HttpStatusCode.OK, await Get(client, "/legacy-only", Tokens.LegacyToken())); // A valid Entra ID token is ignored here, not merely insufficient: // the same token succeeds at /either right below. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only", Tokens.EntraToken())); Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/legacy-only"));
Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/either", Tokens.EntraToken())); }
public static async Task SelectorRoutesOnTheUnvalidatedIssuer() { await using var app = RoutedApp(); var client = app.GetTestClient();
// A token this app never routes to the wrong handler proves the // selector chose correctly: a Legacy token only ever validates // against Legacy's key and issuer, an Entra one only against // EntraId's, so either succeeding at all shows it reached the // handler that can actually accept it. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets")); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.LegacyToken())); Assert.Equal(HttpStatusCode.OK, await Get(client, "/widgets", Tokens.EntraToken())); }
public static async Task SelectorIsOnlyARoutingHint() { await using var app = RoutedApp(); var client = app.GetTestClient();
// Same shape as a real Entra token - issuer and audience Entra // would issue - but signed with the Legacy scheme's key: what an // attacker who edits only the unvalidated iss claim of an // otherwise-useless token can produce, having no way to sign with // Entra's real key. The selector still reads it as an Entra // issuer and forwards it there, and it is still rejected, on // signature, exactly as any other forged token would be. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", Tokens.ForgedEntraLookingToken()));
// Not a JWT at all: CanReadToken is false, so the selector falls // through to Legacy without ever reading a claim, and Legacy's own // validation - not an unhandled exception - is what turns it into // a 401. Assert.Equal(HttpStatusCode.Unauthorized, await Get(client, "/widgets", "not-a-jwt-at-all")); }
static WebApplication RoutedApp() => BuildApp( builder => { var auth = builder.Services.AddAuthentication(PolicyScheme) .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation); AddRoutingScheme(auth); builder.Services.AddAuthorization(); }, app => app.MapGet("/widgets", [Authorize] () => Results.Ok()));
static void RegisterLocalSchemes(WebApplicationBuilder builder) => builder.Services.AddAuthentication() .AddJwtBearer("Legacy", o => o.TokenValidationParameters = Tokens.LegacyValidation) .AddJwtBearer("EntraId", o => o.TokenValidationParameters = Tokens.EntraValidation);
static WebApplication BuildApp(Action<WebApplicationBuilder> configureAuth, Action<WebApplication> mapEndpoints) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); configureAuth(builder); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); mapEndpoints(app); app.StartAsync().GetAwaiter().GetResult(); return app; }
static async Task<HttpStatusCode> Get(HttpClient client, string path, string? token = null) { var request = new HttpRequestMessage(HttpMethod.Get, path); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return (await client.SendAsync(request)).StatusCode; }
static void Register(WebApplicationBuilder builder) { builder.Services.AddAuthentication() // the existing scheme, unchanged apart from the explicit name .AddJwtBearer("Legacy", options => { options.Authority = builder.Configuration["Legacy:Authority"]; options.Audience = builder.Configuration["Legacy:Audience"]; }) // Entra ID, registered under its own name instead of "Bearer" .AddMicrosoftIdentityWebApi( builder.Configuration.GetSection("AzureAd"), jwtBearerScheme: "EntraId"); }
static void UseBothSchemesByDefault(IServiceCollection services) { var bothSchemes = new AuthorizationPolicyBuilder("Legacy", "EntraId") .RequireAuthenticatedUser() .Build();
services.AddAuthorizationBuilder() .SetDefaultPolicy(bothSchemes); }
static void MapEndpoints(WebApplication app) { // only the legacy scheme runs here; an Entra ID token is ignored, not merely insufficient app.MapGet("/legacy-only", [Authorize(AuthenticationSchemes = "Legacy")] () => Results.Ok()) .WithName("LegacyOnly");
// both handlers run; either a valid Legacy or a valid EntraId token succeeds app.MapGet("/either", [Authorize(AuthenticationSchemes = "Legacy,EntraId")] () => Results.Ok()) .WithName("Either"); }
const string PolicyScheme = "LegacyOrEntraId";
static void AddRoutingScheme(AuthenticationBuilder auth) => auth.AddPolicyScheme(PolicyScheme, displayName: null, options => { options.ForwardDefaultSelector = context => { string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) return "Legacy"; // no bearer token at all: let Legacy's handler produce the 401
var token = authorization["Bearer ".Length..].Trim(); var handler = new JsonWebTokenHandler(); if (handler.CanReadToken(token)) { var issuer = handler.ReadJsonWebToken(token).Issuer; if (issuer.StartsWith("https://login.microsoftonline.com/")) return "EntraId"; } return "Legacy"; }; });}
static class Tokens{ const string LegacyIssuer = "https://legacy.example.com"; const string LegacyAudience = "api://legacy"; const string EntraIssuer = "https://login.microsoftonline.com/test-tenant/v2.0"; const string EntraAudience = "api://entra";
static readonly SigningCredentials LegacyCredentials = NewCredentials(); static readonly SigningCredentials EntraCredentials = NewCredentials();
public static readonly TokenValidationParameters LegacyValidation = ValidationFor(LegacyIssuer, LegacyAudience, LegacyCredentials); public static readonly TokenValidationParameters EntraValidation = ValidationFor(EntraIssuer, EntraAudience, EntraCredentials);
static SigningCredentials NewCredentials() => new(new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)), SecurityAlgorithms.HmacSha256);
static TokenValidationParameters ValidationFor(string issuer, string audience, SigningCredentials credentials) => new() { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateIssuerSigningKey = true, IssuerSigningKey = credentials.Key, ValidateLifetime = true, };
static string Create(string issuer, string audience, SigningCredentials credentials) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = issuer, Audience = audience, SigningCredentials = credentials, NotBefore = DateTime.UtcNow.AddMinutes(-1), Expires = DateTime.UtcNow.AddMinutes(5), });
public static string LegacyToken() => Create(LegacyIssuer, LegacyAudience, LegacyCredentials); public static string EntraToken() => Create(EntraIssuer, EntraAudience, EntraCredentials); public static string ForgedEntraLookingToken() => Create(EntraIssuer, EntraAudience, LegacyCredentials);}builder.Services.AddAuthentication(PolicyScheme).AddJwtBearer("Legacy", ...).AddMicrosoftIdentityWebApi(...)
registers the two real schemes exactly as in the registration above; AddRoutingScheme here adds the
policy scheme on top of that same builder and, by naming it in AddAuthentication(PolicyScheme) instead
of leaving the default scheme unset, makes it the one an unqualified [Authorize] runs: the selector
runs once and forwards to exactly one real handler instead of both.
The trap: the selector reads the token, it does not validate it
Section titled “The trap: the selector reads the token, it does not validate it”ReadJsonWebToken decodes the token’s claims without checking its signature, issuer, audience or
expiry - the same four checks the Entra ID validation entry
covers are still done afterward, by whichever scheme’s AddJwtBearer/AddMicrosoftIdentityWebApi
handler the selector forwarded to. This is safe precisely because the selector’s answer is only a
routing hint: an attacker who edits the iss claim of a garbage or expired token to read
https://login.microsoftonline.com/... gets forwarded to the EntraId handler, which then rejects
it on signature or audience exactly as it would any other forged token - the forwarding decision
never substitutes for validation, it only picks which validator runs. Treating an unvalidated claim
read this way as authorization (skipping the real handler, or trusting a sub or roles claim off
of it directly) would undo that safety; the selector must only ever return a scheme name.
The flow, start to finish
Section titled “The flow, start to finish”Nothing before the chosen handler ever validates the token; the selector’s read is only a routing hint, and the four checks still run afterward, in whichever handler it picked:
flowchart TD
Req[Request arrives] --> Hdr{Bearer token?}
Hdr -- no --> Legacy[Legacy handler]
Hdr -- yes --> Iss{iss looks like Entra?}
Iss -- no --> Legacy
Iss -- yes --> Entra[EntraId handler]
Legacy --> LOk{Validates fully?}
Entra --> EOk{Validates fully?}
LOk -- yes --> Principal[Principal]
EOk -- yes --> Principal
LOk -- no --> Reject[401]
EOk -- no --> RejectThe “iss looks like Entra?” step is ForwardDefaultSelector’s own unvalidated read; “Validates fully?”
is the same handler running the real signature, issuer, audience and expiry checks a moment later, so a
forged iss can steer a request to the EntraId box but never past it.
How to verify it worked
Section titled “How to verify it worked”Call a shared endpoint three ways: a valid legacy token, a valid Entra ID token, and no token at all.
Each valid token should succeed and User.Identity.AuthenticationType (or a claim only that issuer
sets) should show which handler actually ran; the no-token case should still 401, not 500 - a
500 there usually means the selector’s fallback branch returned a scheme name that isn’t
registered, or CanReadToken was skipped for a value that isn’t a JWT at all (an opaque token, or
Bearer with nothing after it) and threw instead of falling through. A request with an expired
legacy token and a syntactically valid Entra ID iss claim pasted into it is the concrete check that
routing and validation are actually separate: it should be forwarded to EntraId and rejected there
on signature, never accepted because the routing step merely recognized the issuer string.
When this does not apply
Section titled “When this does not apply”A one-time cutover - every existing client will eventually reissue against Entra ID and the legacy scheme is retired on a fixed date - is simpler served by swapping the single registration for Entra ID’s outright and giving old clients a deprecation window, rather than carrying two schemes (and the selector logic to route between them) as permanent infrastructure. Two schemes are worth the complexity above only when both kinds of caller are expected to keep authenticating independently for the foreseeable future.
History: 4 changes
- Example changed
- Body changed; example changed
- Made public
- Created