Most ASP.NET Core auth tutorials assume a single JWT scheme: one token, one identity, one [Authorize] and you're done. Real systems don't always fit that shape. On a production system I worked on, we needed a second, independently-issued JWT for a narrower purpose alongside the main session token — and getting the two schemes to coexist cleanly (without silently breaking claim lookups) took longer than it should have.
Why two schemes
The main API surface authenticates with a standard Authorization: Bearer token issued at login. Separately, a specific set of endpoints needed to authenticate a secondary JWT — issued for a narrower, scoped context — carried in a custom header rather than the standard Authorization header. Trying to force both use cases through a single scheme meant either overloading one token with responsibilities it shouldn't have, or writing brittle conditional logic inside a single JwtBearerEvents handler. Registering two named schemes turned out to be far cleaner.
Registering both schemes
services.AddAuthentication()
.AddJwtBearer("MainScheme", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
// issuer/audience/key config here
};
})
.AddJwtBearer("UserTokenScheme", options =>
{
options.MapInboundClaims = false; // more on this below
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
context.Token = context.Request.Headers["X-User-Token"];
return Task.CompletedTask;
}
};
});
Each scheme gets a distinct name, and [Authorize(AuthenticationSchemes = "UserTokenScheme")] on a controller or action routes that specific endpoint to check the right token, extracted from the right place.
The MapInboundClaims gotcha
This is the part that actually cost the time. By default, JwtBearerOptions.MapInboundClaims is true, which means ASP.NET Core silently remaps standard JWT claim types on the way in — sub becomes ClaimTypes.NameIdentifier, email becomes ClaimTypes.Email, and so on, using the legacy .NET claim type URIs.
If your code reads claims by their raw JWT names (context.User.FindFirst("sub")), this remapping means the claim you're looking for silently isn't there under that name anymore — no exception, no error, just a null where you expected a value. It's a quiet failure mode that's easy to burn an afternoon on, because everything looks correct: the token is valid, the request is authenticated, but a specific claim lookup keeps failing.
The fix is one line:
options.MapInboundClaims = false;
With this set, HttpContext.User.Claims reflects the JWT's claim types exactly as they were issued — no remapping. Worth deciding deliberately per-scheme rather than hitting it by accident, especially if one scheme's tokens were issued by code that assumes the raw claim names and the other assumes the mapped ClaimTypes.* values.
Keeping the frontend agnostic
On the client side, permission and identity checks shouldn't need to know or care which scheme authenticated a given request. A thin context/helper layer that reads claims generically (rather than hardcoding assumptions about which scheme produced them) keeps this detail contained to the backend, where it belongs.
Checklist
- [ ] Are your schemes named explicitly, with
[Authorize(AuthenticationSchemes = "...")]used per endpoint rather than relying on a default? - [ ] Have you checked
MapInboundClaimson each scheme if claim lookups aren't behaving as expected? - [ ] Is the second token's source (header, cookie, query string) documented clearly for other developers touching the code?
- [ ] Are validation parameters (issuer, audience, lifetime, signing key) configured independently per scheme, not assumed to be shared?
Running two JWT schemes side by side isn't exotic once it's set up, but the defaults in ASP.NET Core's JwtBearerOptions — particularly MapInboundClaims — are exactly the kind of thing that looks fine until it silently isn't.
Top comments (0)