# Roles from your sign-in

> Make the roles of your sign-in reach RequiredRoles: JWT bearer tokens, Microsoft Entra ID app roles and groups, Windows groups, and roles from your database.

- URL: https://docs.mentoragent.net/configure/roles-from-sign-in
- Verified with: MentorAgent 1.2.0
- Last updated: 2026-11-09
- Packages: Novior.MentorAgent, Novior.MentorAgent.Server

Use this page when your users sign in with JSON Web Tokens (JWT), Microsoft Entra ID or Windows, and an action with `RequiredRoles` is refused for a user who holds the role. MentorAgent does not sign users in and keeps no list of roles: it asks the signed-in user, as ASP.NET Core builds it. The roles therefore have to be on that user, in the claim that the user's identity names as its role claim. This page shows how to get them there for each kind of sign-in.

## How MentorAgent checks a role

Every example on this page uses this action:

```csharp title="CaseApprovals.cs"
using Novior.MentorAgent.Attributes;

public sealed class CaseApprovals
{
    public List<string> Approved { get; } = [];

    // Offered only to users in the Supervisors role; it asks for confirmation
    [MentorAction(Description = "Approves the refund of a support case",
        RequiredRoles = ["Supervisors"], RequiresConfirmation = true)]
    public string ApproveRefund(string caseId)
    {
        Approved.Add(caseId);
        return $"Refund approved for {caseId}";
    }
}
```

- **The check is `ClaimsPrincipal.IsInRole`.** Any one of the roles in `RequiredRoles` is enough. The user must be signed in: for an anonymous user, the action is refused.
- **`IsInRole` reads the claim that each identity names as its role claim**, its `RoleClaimType`. By default that is `ClaimTypes.Role`. A `roles` claim counts only when the identity names `roles` as its role claim.
- **Role names in claims are case-sensitive.** `supervisors` does not match `Supervisors`.
- **A user without the role is never offered the action**, and a call made anyway is refused before any confirmation box: see [Two checks, in this order](https://docs.mentoragent.net/concepts/governance#two-checks-in-this-order).
- **The model is told the user's roles**: the values of the role claims of every identity of the user. Windows groups are left out, because they are security identifiers that name nothing the model can use.

Where MentorAgent finds the user depends on the channel:

| Where the request comes from | The user is |
|---|---|
| The SignalR hub, `/mentor/chat` and Agent-to-Agent (A2A) | The user of the request, `HttpContext.User`, after ASP.NET Core authentication |
| A Blazor Server [circuit](https://docs.mentoragent.net/reference/glossary#circuit) | The user that the `AuthenticationStateProvider` reports |
| The `/mcp` endpoint | The user that [`McpCallerPrincipal`](https://docs.mentoragent.net/reference/options#mcpcallerprincipal) returns |

So everything on this page happens in ASP.NET Core authentication, before MentorAgent sees the user. A claim that only your own `AuthenticationStateProvider` adds is not seen on the hub, on `/mentor/chat` or over A2A: add it with an [`IClaimsTransformation`](https://docs.mentoragent.net/configure/roles-from-sign-in#roles-from-your-own-database) instead.

## JWT bearer tokens

With the default settings of `JwtBearer`, the `role` and `roles` claims of a token become `ClaimTypes.Role` claims, and `IsInRole` finds them with no extra setting. The [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints#choose-the-authentication-scheme) page uses these defaults.

Many applications turn this mapping off, with `MapInboundClaims = false`, to keep the claim names of the token, such as `sub` and `name`. Then the roles stay in a `roles` claim, and the identity must be told about it:

```csharp title="Program.cs"
using Microsoft.AspNetCore.Authentication;           // IClaimsTransformation
using Microsoft.AspNetCore.Authentication.JwtBearer; // JwtBearerDefaults
```

```csharp title="Program.cs"
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];
        // Keep the claim names of the token: "sub", "name", "roles"
        options.MapInboundClaims = false;
        // and name the claims that hold the user's name and roles
        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "roles";
    });
```

Without `RoleClaimType = "roles"`, `IsInRole` looks for `ClaimTypes.Role`, finds nothing, and every action with `RequiredRoles` stays refused. `NameClaimType` does the same for the user's name, which MentorAgent uses to identify the user when the token has no `sub` or `oid`.

## Microsoft Entra ID

### App roles

App roles are the simplest way to bring roles from Microsoft Entra ID. You define them in the app registration of your API, under **App roles**, and assign users or groups to them in the enterprise application. Entra ID then puts the user's roles in the `roles` claim of the access token, by their value, such as `Supervisors`. Use the same values in `RequiredRoles`.

For an API that validates Entra ID access tokens with `JwtBearer`:

```csharp title="Program.cs"
var tenant = builder.Configuration["Entra:TenantId"];
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Access tokens that Microsoft Entra ID issues for your API
        options.Authority =
            $"https://login.microsoftonline.com/{tenant}/v2.0";
        options.Audience = builder.Configuration["Entra:ClientId"];
        options.MapInboundClaims = false;
        options.TokenValidationParameters.NameClaimType = "name";
        // App roles arrive in "roles"
        options.TokenValidationParameters.RoleClaimType = "roles";
    });

// Group object IDs become role names: see EntraGroupRoles
builder.Services.AddTransient<IClaimsTransformation, EntraGroupRoles>();
```

- **The audience** is the client ID of your API's app registration, the value that Entra ID writes in the `aud` claim of version 2.0 tokens.
- **Another library that validates the token**, such as Microsoft.Identity.Web, follows the same rule: the identity's `RoleClaimType` must be the claim that holds the roles. If a user who holds the role is refused, check it as in [Common errors](https://docs.mentoragent.net/configure/roles-from-sign-in#common-errors).
- **The last line** registers the group mapping below. Leave it out if you use app roles only.

### Groups

You can also ask Entra ID for the user's groups, in the app registration under **Token configuration**. The `groups` claim then lists them, but by object ID, a value such as `11111111-1111-1111-1111-111111111111`, never by name. An object ID in `RequiredRoles` would work, but it says nothing to a reader of your code. Map the IDs to role names instead, with an `IClaimsTransformation`:

```csharp title="EntraGroupRoles.cs"
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;   // IClaimsTransformation

// Gives the members of some Entra ID groups a role of your application.
// The token lists the groups by object ID, never by name.
public sealed class EntraGroupRoles(
    IConfiguration configuration, ILogger<EntraGroupRoles> logger)
    : IClaimsTransformation
{
    const string Source = "EntraGroupRoles";

    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        // ASP.NET Core can call this more than once for the same user
        if (principal.Identities.Any(i => i.AuthenticationType == Source))
            return Task.FromResult(principal);

        // Above the limit, the token points to Microsoft Graph instead
        if (principal.HasClaim(c => c.Type == "hasgroups"
            || c.Type == "_claim_names" && c.Value.Contains("\"groups\"")))
        {
            logger.LogWarning("Too many groups in the token: read them " +
                "from Microsoft Graph, or assign groups to the application.");
            return Task.FromResult(principal);
        }

        // Configuration "Roles:Groups": group object ID -> role name
        var map = configuration.GetSection("Roles:Groups");
        var roles = principal.FindAll("groups")
            .Select(group => map[group.Value])
            .OfType<string>()
            .Distinct()
            .Select(role => new Claim(ClaimTypes.Role, role))
            .ToList();
        if (roles.Count == 0)
            return Task.FromResult(principal);

        // The roles go in an identity of their own, next to the token's
        var withRoles = principal.Clone();
        withRoles.AddIdentity(new ClaimsIdentity(
            roles, Source, ClaimTypes.Name, ClaimTypes.Role));
        return Task.FromResult(withRoles);
    }
}
```

The mapping goes in your configuration:

```json
{
  "Roles": {
    "Groups": {
      "11111111-1111-1111-1111-111111111111": "Supervisors"
    }
  }
}
```

- **The roles go in an identity of their own**, next to the one from the token, with `ClaimTypes.Role` as its role claim. `IsInRole` reads every identity of the user, so it finds them.
- **ASP.NET Core can call the transformation more than once** for the same user, so it checks first whether it has already run.
- **Above a limit, the token has no `groups` claim.** When a user belongs to more than 200 groups, Entra ID leaves the `groups` claim out of a JWT (150 in a SAML token). It writes `_claim_names` and `_claim_sources` instead, and `hasgroups` in the implicit flow. The transformation above then gives no role and logs a warning. Either read the groups from Microsoft Graph in your transformation, or set the groups claim to **Groups assigned to the application**: the token then lists only the groups assigned to your application, of which the user is a direct member.

App roles have no such limit, and their values are names: prefer them when you can.

## Windows and Active Directory

With Windows authentication, the user is a `WindowsPrincipal`, and its roles are the user's Windows groups. Write a group in `RequiredRoles` as `DOMAIN\Group`:

```csharp title="CaseRefunds.cs"
public sealed class CaseRefunds
{
    // A Windows group, written as DOMAIN\Group
    [MentorAction(Description = "Approves the refund of a support case",
        RequiredRoles = [@"CONTOSO\Case Supervisors"],
        RequiresConfirmation = true)]
    public string ApproveCaseRefund(string caseId) =>
        $"Refund approved for {caseId}";
}
```

`WindowsPrincipal.IsInRole` resolves the name against the user's groups, the nested ones included. The model is not told these groups: see [How MentorAgent checks a role](https://docs.mentoragent.net/configure/roles-from-sign-in#how-mentoragent-checks-a-role).

The server signs the user in. Under IIS, with the app in the IIS process (the default), turn on Windows authentication for the site and use the IIS scheme:

```csharp title="Program.cs"
using Microsoft.AspNetCore.Server.HttpSys;   // HttpSysDefaults and others
using Microsoft.AspNetCore.Server.IIS;       // IISServerDefaults
using Novior.MentorAgent.Attributes;
```

```csharp title="Program.cs"
// IIS signs the user in; the app runs in the IIS process
builder.Services.AddAuthentication(IISServerDefaults.AuthenticationScheme);
```

```xml
<!-- web.config -->
<system.webServer>
  <security>
    <authentication>
      <anonymousAuthentication enabled="false" />
      <windowsAuthentication enabled="true" />
    </authentication>
  </security>
</system.webServer>
```

Without IIS, on Windows, HTTP.sys signs the user in itself:

```csharp title="Program.cs"
// HTTP.sys signs the user in, without IIS
builder.WebHost.UseHttpSys(options =>
{
    options.Authentication.Schemes =
        AuthenticationSchemes.Negotiate | AuthenticationSchemes.NTLM;
    options.Authentication.AllowAnonymous = false;
});
builder.Services.AddAuthentication(HttpSysDefaults.AuthenticationScheme);
```

On Kestrel, and on Linux or macOS, Windows authentication comes from the `Microsoft.AspNetCore.Authentication.Negotiate` package. Add it to your project:

```bash
dotnet add package Microsoft.AspNetCore.Authentication.Negotiate
```

```csharp title="Program.cs"
using Microsoft.AspNetCore.Authentication.Negotiate;   // NegotiateDefaults
```

```csharp title="Program.cs"
// Kestrel signs the user in with Kerberos or NTLM, on any system
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
    .AddNegotiate(options =>
    {
        // On Linux and macOS a Kerberos sign-in brings no groups:
        // read them from Active Directory
        if (!OperatingSystem.IsWindows())
            options.EnableLdap(builder.Configuration["ActiveDirectory:Domain"]
                ?? throw new InvalidOperationException(
                    "Set ActiveDirectory:Domain, for example contoso.com."));
    });

// Every request signs in, so every user has their groups
builder.Services.AddAuthorization(options =>
    options.FallbackPolicy = options.DefaultPolicy);
```

- **On Windows** the user's groups come with the sign-in, as with IIS and HTTP.sys.
- **On Linux and macOS** a Kerberos sign-in brings no groups. The handler reads them from Active Directory only with `EnableLdap`, as above. The host also needs a keytab for its service account: see [Configure Windows Authentication in ASP.NET Core](https://learn.microsoft.com/aspnet/core/security/authentication/windowsauth).
- **Every request signs in.** Without the fallback policy, a request that does not ask for authentication stays anonymous, and an action with `RequiredRoles` is refused.

## Roles from your own database

When your application keeps the roles itself, for example in a table per user, add them to the signed-in user with an `IClaimsTransformation`. ASP.NET Core runs it on every authenticated request, so the roles reach the hub, `/mentor/chat`, A2A and a Blazor Server circuit alike:

```csharp title="RolesFromStore.cs"
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;   // IClaimsTransformation

// Your own list of roles per user, for example a table in your database
public interface ICaseRoleStore
{
    Task<IReadOnlyList<string>> GetRolesAsync(string userId);
}

// Adds the roles that your application keeps for the signed-in user
public sealed class RolesFromStore(ICaseRoleStore store) : IClaimsTransformation
{
    const string Source = "RolesFromStore";

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        if (principal.Identity?.IsAuthenticated != true
            || principal.Identities.Any(i => i.AuthenticationType == Source))
            return principal;

        // The user's ID from the sign-in: NameIdentifier, or "oid" or "sub"
        var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
            ?? principal.FindFirst("oid")?.Value
            ?? principal.FindFirst("sub")?.Value;
        if (userId is null)
            return principal;

        var roles = await store.GetRolesAsync(userId);
        var withRoles = principal.Clone();
        withRoles.AddIdentity(new ClaimsIdentity(
            roles.Select(role => new Claim(ClaimTypes.Role, role)),
            Source, ClaimTypes.Name, ClaimTypes.Role));
        return withRoles;
    }
}
```

Register it with your other services:

```csharp title="Program.cs"
builder.Services.AddScoped<IClaimsTransformation, RolesFromStore>();
```

- **It runs on every request**, so keep it fast: cache the roles if your store is slow.
- **A connection keeps the user it started with.** The hub reads the user when the connection opens, and a Blazor Server circuit when it starts. A role that you change reaches an open connection when it reconnects.
- **ASP.NET Core uses one `IClaimsTransformation`.** To combine the group mapping and your database, do both in one class.

## Common errors

- **A user who holds the role is refused, with a warning in the log for the refused call.** The roles are in a claim that the identity does not name as its role claim, such as `roles` with `MapInboundClaims = false`. Set `RoleClaimType`. To see what the user has, log `User.Identities.Select(i => i.RoleClaimType)` and the claims once, in a test endpoint.
- **It works with a different case.** Role names in claims are case-sensitive: write them in `RequiredRoles` as the token writes them.
- **It works on the page but not on the hub, on `/mentor/chat` or over A2A.** Your own `AuthenticationStateProvider` adds the roles, and these endpoints do not ask it. Move the code to an `IClaimsTransformation`.
- **Entra ID groups give no role.** The `groups` claim holds object IDs, not names: map them as in [Groups](https://docs.mentoragent.net/configure/roles-from-sign-in#groups). If the token has `_claim_names` and no `groups`, the user is over the group limit.
- **Windows groups give no role on Linux.** Negotiate needs `EnableLdap` to read the groups there.
- **Every action with `RequiredRoles` is refused for everybody.** Nobody is signed in on that channel: the endpoint does not require authentication, or the request carries no credentials. See [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints).

---

- Previous: [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints.md)
- Next: [Test without a model](https://docs.mentoragent.net/configure/test-without-model.md)
