A common problem when protecting your ASP.NET Core APIs is that expected claims are not found in the user object. In this blog post, I will give you some ideas for how to diagnose these types of problems. If you have claim problems related to the OpenIDConnect handler, head over to the blog post about Debugging OpenID Connect Claim Problems in ASP.NET Core.
What is the purpose of the JwtBearer handler?
We use the JwtBearer authentication handler to protect ASP.NET Web APIs. Its primary purpose is to look for an access token in the incoming request, and if one is found, validate it and create a user object of type ClaimsPrincipal. JwtBearer only deals with access tokens, and it is essential to remember that you should never try to send an ID or refresh tokens to APIs.
First problem: Are the expected claims inside the received access token?
Using a tool like Fiddler, you can capture the raw access token from the incoming request and then use a site like jwt.io to inspect what the access token contains.
The token is typically included in the Authorization header, as shown below:
GET https://api.tn-data.se:7001/api/payments HTTP/1.1
Host: api.tn-data.se
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjU1...
If the token is not found here, then you have problems with the client that sends the token and how to troubleshoot that is beyond the scope of this blog post.
How can I see the received access token using .NET code?
If you can’t use a tool like Fiddler to capture the access token, then you can for example, add the following event handler to the JwtBearer handler:
.AddJwtBearer(opt =>
{
//...
opt.Events = new JwtBearerEvents()
{
OnMessageReceived = msg =>
{
var token = msg?.Request.Headers.Authorization.ToString();
string path = msg?.Request.Path ?? "";
if (!string.IsNullOrEmpty(token))
{
Console.WriteLine("Access token");
Console.WriteLine($"URL: {path}");
Console.WriteLine($"Token: {token}\r\n");
}
else
{
Console.WriteLine("Access token");
Console.WriteLine("URL: " + path);
Console.WriteLine("Token: No access token provided\r\n");
}
return Task.CompletedTask;
}
};
});
(Alternatively, you can send the information to the log if writing to the console is not possible.)
A sample output when using the code above:
Access token
URL: /api/payments
Token: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjYwODE2RTk1MUU5NEJDMjEzNzEzN0Y0MUNGN0YwRDg3IiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwczovL2lkZW…
It is important to not include this code in production, as writing to the console can result in a performance hit and writing to the console is an expensive operation.
How can I view the claims that the JwtBearer handler received?
To see a list of all the received claims, you can add the following event handler to JwtBearer:
.AddJwtBearer(opt =>
{
//...
opt.Events = new JwtBearerEvents()
{
//...
OnTokenValidated = ctx =>
{
Console.WriteLine();
Console.WriteLine("Claims from the access token");
if (ctx?.Principal != null)
{
foreach (var claim in ctx.Principal.Claims)
{
Console.WriteLine($"{claim.Type} - {claim.Value}");
}
}
Console.WriteLine();
return Task.CompletedTask;
}
};
});
Running this code, you should see a list of the claims extracted from the access token written to the console. For example, it could look like this:
Claims from the access token
iss - https://localhost:6001
nbf - 1682863284
iat - 1682863284
exp - 1682923284
aud - payment
aud - invoice
aud - order
scope - openid
scope - profile
scope - email
scope - employee_info
scope - api
http://schemas.microsoft.com/claims/authnmethodsreferences - pwd
client_id - missingjwtbearer-claims-client
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier - 2
auth_time - 1682863282
http://schemas.microsoft.com/identity/claims/identityprovider - local
name - Bob Smith
seniority - Senior
contractor - no
http://schemas.microsoft.com/ws/2008/06/identity/claims/role - ceo
http://schemas.microsoft.com/ws/2008/06/identity/claims/role - finance
http://schemas.microsoft.com/ws/2008/06/identity/claims/role - developer
sid - C3768153BD5988F9B8B56D4A3AD518A1
jti - 5DB88EE876EF1B449E86930CE747AF3B
As you might notice above, some claims have been renamed to what Microsoft thinks the claims should be named.
You can disable this renaming by setting this flag to false:
.AddJwtBearer(opt =>
{
// ...
opt.MapInboundClaims = false;
// ...
});
If you set the MapInboundClaims flag to false, then the output in my sample application changes to:
Claims from the access token
iss - https://localhost:6001
nbf - 1682863284
iat - 1682863284
exp - 1682923284
aud - payment
aud - invoice
aud - order
scope - openid
scope - profile
scope - email
scope - employee_info
scope - api
amr - pwd
client_id - missingjwtbearer-claims-client
sub - 2
auth_time - 1682863282
idp - local
name - Bob Smith
seniority - Senior
contractor - no
role - ceo
role - finance
role - developer
sid - C3768153BD5988F9B8B56D4A3AD518A1
jti - 5DB88EE876EF1B449E86930CE747AF3B
Fixing the ASP.NET Core username and roles properties
The next problem is fixing the name and roles of the user. Again, this is a common problem that I see on Stack Overflow.
To demonstrate this, I have created the following API action method:
[Authorize]
public ActionResult Get()
{
var result = new Result();
result.Name = User?.Identity?.Name ?? "Unknown Name";
result.IsInRoleCEO = User?.IsInRole("ceo");
result.Claims = User?.Claims.Select(c => c.Type + ":" + c.Value)?.ToList();
return Ok(result);
}
public class Result
{
public string? Name { get; set; }
public bool? IsInRoleCEO { get; set; }
public List? Claims { get; set; }
}
The Name and IsInRoleCEO does not work. Why?
The short answer is that Microsoft has a different opinion of what the name of the claims should be when it looks for the name and role.
By default, it will look for these two claim types:
- http://schemas.microsoft.com/ws/2008/06/identity/claims/name
- http://schemas.microsoft.com/ws/2008/06/identity/claims/role
They are defined in the .NET source code here
To verify this, you can add the following code to a controller method, and it will print the name of the role/name claim type that it looks for:
var user = (ClaimsIdentity)User.Identity;
Console.WriteLine("Name claim type: " + user.NameClaimType);
Console.WriteLine("Role claim type: " + user.RoleClaimType);
This is the output:
Name claim type: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
Role claim type: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
To fix this, we need to set the name of your name/role claim to match what the claims are named inside the token. For example, in my case, the claims are named name and role:
.AddJwtBearer(opt =>
{
// ...
opt.TokenValidationParameters.RoleClaimType = "role";
opt.TokenValidationParameters.NameClaimType = "name";
// ...
});
With the code above in place, the code below should start to work as expected:
result.Name = User?.Identity?.Name ?? "Unknown Name";
result.IsInRoleCEO = User?.IsInRole("ceo");
In my example application, they will return:
…
"name": "Bob Smith",
"isInRoleCEO": true,
…
Final ASP.NET Core code:
The code for the API startup and sample controller:
Program class:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.Authority = "[MyAuthority]";
opt.Audience = "myaudience";
opt.TokenValidationParameters.RoleClaimType = "role";
opt.TokenValidationParameters.NameClaimType = "name";
opt.MapInboundClaims = false;
opt.Events = new JwtBearerEvents()
{
OnMessageReceived = msg =>
{
var token = msg?.Request.Headers.Authorization.ToString();
string path = msg?.Request.Path ?? "";
if (!string.IsNullOrEmpty(token))
{
Console.WriteLine("Access token");
Console.WriteLine($"URL: {path}");
Console.WriteLine($"Token: {token}\r\n");
}
else
{
Console.WriteLine("Access token");
Console.WriteLine("URL: " + path);
Console.WriteLine("Token: No access token provided\r\n");
}
return Task.CompletedTask;
}
,
OnTokenValidated = ctx =>
{
Console.WriteLine();
Console.WriteLine("Claims from the access token");
if (ctx?.Principal != null)
{
foreach (var claim in ctx.Principal.Claims)
{
Console.WriteLine($"{claim.Type} - {claim.Value}");
}
}
Console.WriteLine();
return Task.CompletedTask;
}
};
});
Payment controller:
[Route("api/[controller]")]
[ApiController]
public class PaymentsController : ControllerBase
{
[Authorize]
public ActionResult Get()
{
var user = (ClaimsIdentity)User.Identity;
Console.WriteLine("Name claim type: " + user.NameClaimType);
Console.WriteLine("Role claim type: " + user.RoleClaimType);
var result = new Result();
result.Name = User?.Identity?.Name ?? "Unknown Name";
result.IsInRoleCEO = User?.IsInRole("ceo");
result.Claims = User?.Claims.Select(c => c.Type + ":" + c.Value)?.ToList();
return (result);
}
}
public class Result
{
public string? Name { get; set; }
public bool? IsInRoleCEO { get; set; }
public List? Claims { get; set; }
}
Claims diagnostic endpoint
It can be useful to create a diagnostic endpoint for debugging purposes that simply returns the claims found in the User object. Here’s a sample endpoint for ASP.NET Core:
[Authorize]
[Route("/api/tokendiagnostics")]
public IActionResult GetClaims()
{
var result = new TestResult()
{
Name = User.Identity?.Name ?? "Unknown Name",
Claims = (from c in User.Claims select c.Type + ":" + c.Value).ToList()
};
return new JsonResult(result);
}
public class TestResult
{
public string? Name { get; set; }
public List? Claims { get; set; }
}
Conclusions
I hope the above steps can help you troubleshoot your JwtBearer claim problems. If you have any suggestions for this blog post, feedback, questions, or would like to get in touch, you can find my contact details here.
About the author
Hi, I’m Tore! I have been fascinated by computers since I unpacked my first Commodore VIC-20. Today, I enjoy providing freelance development and developer training services, focusing on ASP.NET Core, IdentityServer, OpenID Connect, Architecture, and Web Security. You can connect with me on LinkedIn and Twitter. You can also find out more about me and my services there, as well as my classes for developers, including my course Introduction to IdentityServer and OpenID-Connect.
Tore’s Newsletter
Be the First to Know! Get notified about my latest blog posts, upcoming presentations, webinars, and more — subscribe today!