# Secure the endpoints

> Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.

- URL: https://docs.mentoragent.net/configure/secure-endpoints
- Verified with: MentorAgent 1.0.0
- Last updated: 2026-10-13
- Packages: Novior.MentorAgent.Server, Novior.MentorAgent

Every endpoint that MentorAgent maps is open until you protect it: the package cannot know how your application signs users in. This page puts authentication on each endpoint, with Cross-Origin Resource Sharing (CORS) in the right place.

## When you need it

Do it before anyone but you can reach the server. It applies to the endpoints of `Novior.MentorAgent.Server`: the hub, `/mentor/chat`, `/mcp` and `/a2a`. On [Blazor Server](https://docs.mentoragent.net/configure/blazor-server) the widget talks through the [circuit](https://docs.mentoragent.net/reference/glossary#circuit), which your app's authentication already covers: only the endpoints of the [MCP server](https://docs.mentoragent.net/configure/mcp-server) and the [A2A server](https://docs.mentoragent.net/configure/a2a-server) are left, if you map them.

### What each endpoint exposes

| Endpoint | Open by default | What an anonymous caller can do | Protect it with |
|---|---|---|---|
| `/mentor-hub` | Yes | Run turns on your model | `configureTransports` |
| `GET` and `POST /mentor/chat` | Yes | Run turns on your model | `configureTransports` |
| `POST /mentor/approve`, `POST /mentor/cancel`, `/mentor/session` | Partly: while a signed-in user's connection is open, only that user is accepted | Nothing, while a signed-in user's connection is open | Built in |
| `GET /mentor/admin/metrics` | No | Nothing: the endpoint requires the `DashboardRole` role | `DashboardRole` |
| `GET /mentor/tour` | Yes, on purpose | Read the tour: public screen names, the same for everyone | Nothing to do |
| `/mcp` | Yes | Call every action that has no roles and does not ask for confirmation | `MapMentorAgentMcp(configure: …)` |
| `/a2a` and `/.well-known/agent-card.json` | Yes | Run tasks on your model and read the card | `MapMentorAgentA2A(configure: …)` |

## Install the packages

The endpoints come with `Novior.MentorAgent.Server`: the hub, Server-Sent Events (SSE), MCP and A2A. Authentication is ASP.NET Core's. For JSON Web Token (JWT) bearer tokens, add its package to the server project, with the same major version as your .NET:

```shell
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

## Minimal configuration

### Choose the authentication scheme

Keep the scheme your application already has: cookies work for a WebAssembly client served by the same host. For clients on other origins, mobile apps and other agents, use bearer tokens:

```csharp title="Program.cs"
using System.Security.Claims;                          // ClaimTypes (complete configuration)
using System.Threading.RateLimiting;                   // RateLimitPartition (complete configuration)
using Novior.MentorAgent.Extensions;                          // MapMentorAgentMcp, MapMentorAgentA2A
using Novior.MentorAgent.Server.Extensions;                   // MapMentorAgentServer
using Microsoft.AspNetCore.Authentication.JwtBearer;   // JwtBearerDefaults, JwtBearerEvents
```

```csharp title="Program.cs"
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];   // your identity provider
        options.Audience = builder.Configuration["Auth:Audience"];

        // A WebSocket handshake cannot carry a header: SignalR sends the token as ?access_token=
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var token = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(token) && context.Request.Path.StartsWithSegments("/mentor-hub"))
                    context.Token = token;
                return Task.CompletedTask;
            }
        };
    });
builder.Services.AddAuthorization();
```

In a browser, WebSocket and Server-Sent Events connections cannot carry an `Authorization` header. There the client, JavaScript or Blazor WebAssembly, sends the token in the `access_token` query string. Outside a browser, the .NET and Java clients send the header. The `OnMessageReceived` event above reads the query string for the hub only: every other endpoint still needs the header. If you map the hub on another path, change `/mentor-hub` in the event too.

### Require authorization on each endpoint

After `Build()`, with MentorAgent and `AddMentorAgentServer()` registered as on the other pages:

```csharp title="Program.cs"
// after var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());   // hub and both /mentor/chat
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization());                // /mcp
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization());                // /a2a and the agent card
```

- `configureTransports` covers the hub and both `/mentor/chat` endpoints, the three that run a turn.
- MCP and A2A are mapped by their own calls, so `configureTransports` does not reach them. Their `configure` callback does, and for A2A it covers the card too.
- Leave out the lines for the endpoints you do not map. `MapMentorAgentMcp()` and `MapMentorAgentA2A()` also need `AddMentorAgentMcp()` and `AddMentorAgentA2A()`. `AddMentorAgentServer()` calls both.

Then send the credentials from each client: `accessTokenFactory` in a [JavaScript client](https://docs.mentoragent.net/configure/any-front-end#complete-configuration), `AccessTokenProvider` in a [WebAssembly client](https://docs.mentoragent.net/configure/blazor-webassembly#complete-configuration), and the [`Headers`](https://docs.mentoragent.net/reference/options#mentorremoteagentheaders) of a `RemoteAgents` entry in another MentorAgent host.

## Complete configuration

A server with browser clients on another origin and other programs on `/mcp` and `/a2a`. The services go next to the JWT registration above:

```csharp title="Program.cs"
// Browser clients on other origins: SignalR sends credentials, so list the origins
builder.Services.AddCors(cors => cors.AddPolicy("MentorClients", policy => policy
    .WithOrigins("https://app.example.com")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

// Other programs call /mcp and /a2a: a limit per caller
builder.Services.AddRateLimiter(limiter =>
{
    limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    limiter.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
        context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(1) }));
});
// after var app = builder.Build();
app.UseCors("MentorClients");   // before authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization().RequireRateLimiting("agents"));
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization().RequireRateLimiting("agents"));
```

- **CORS comes first.** Call `UseCors` before `UseAuthentication` and `UseAuthorization`, and write those two calls yourself. Otherwise the browser's preflight request, which carries no credentials, can be refused before CORS answers it.
- **One CORS policy.** `UseCors("MentorClients")` applies to every endpoint without a policy of its own: the hub, the `/mentor` endpoints, `/a2a` and `/mcp`. A browser client of `/mcp` also needs the `Mcp-Session-Id` header exposed: [MCP server](https://docs.mentoragent.net/configure/mcp-server#complete-configuration) shows a policy of its own for that endpoint.
- **A limit for programs.** `RateLimitPerUser` counts an MCP call in an allowance of its own, apart from chat messages. It counts an A2A task only when the request is authenticated. The ASP.NET Core rate limiter in this example bounds the requests to both endpoints, per caller, whatever MentorAgent sees.

### Options that matter here

| Option | Why it matters for the endpoints |
|---|---|
| [`AnonymousIdentity`](https://docs.mentoragent.net/reference/options#anonymousidentity) | With the default, `PerSession`, every hub connection, every SSE request and every A2A task of an anonymous caller is a new user, with its own memory and its own count. `Shared` puts every anonymous caller on one key: single-user hosts only |
| [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#ratelimitperuser), [`RateLimitWindowSecs`](https://docs.mentoragent.net/reference/options#ratelimitwindowsecs) | Counted by the signed-in user's `NameIdentifier`, else `sub`, `oid` or the name. MCP calls have an allowance of their own. An anonymous caller gets a new allowance with every connection, every SSE request and every A2A task, so only authentication bounds the spend |
| [`UseMemoryContext`](https://docs.mentoragent.net/reference/options#usememorycontext), [`MemoryAutoCapture`](https://docs.mentoragent.net/reference/options#memoryautocapture) | With either on and `/mentor/chat` open to anyone, MentorAgent logs a warning at startup |
| [`DashboardRole`](https://docs.mentoragent.net/reference/options#dashboardrole) | The role that `GET /mentor/admin/metrics` requires. An empty value opens it: development only |
| [`McpCallerPrincipal`](https://docs.mentoragent.net/reference/options#mcpcallerprincipal) | Which [role-gated](https://docs.mentoragent.net/reference/glossary#gated) actions an authenticated MCP caller sees |
| [`MaxMessageLength`](https://docs.mentoragent.net/reference/options#maxmessagelength) | Longer messages are refused before the model is called |

Every option is in [Configuration options](https://docs.mentoragent.net/reference/options), and the limits under [Security and limits](https://docs.mentoragent.net/reference/options#security-and-limits).

## Security and roles

- **Who the user is.** `AddMentorAgentServer()` passes the user of the hub connection, or of the request, to MentorAgent. The user's id is the `NameIdentifier` claim, else `sub`, `oid` or the name. `RequiredRoles` is checked with `IsInRole`. With JWT bearer, the default claim mapping turns the token's `sub` and `role` claims into `NameIdentifier` and `ClaimTypes.Role`. With `MapInboundClaims = false`, the roles need `TokenValidationParameters.RoleClaimType`: see [MCP server](https://docs.mentoragent.net/configure/mcp-server#security-and-roles).
- **Apps with Razor components.** `AddRazorComponents()` and `AddMentorAgentServer()` can come in either order. MentorAgent reads the user of the hub, of `/mentor/chat` and of A2A from the request, before it asks the components' own provider, which works only inside a circuit.
- **Adding claims or roles.** To add claims or roles for these endpoints, use an `IClaimsTransformation`: MentorAgent does not ask a provider of your own there.
- **Without authentication, role-gated actions are refused.** Every action with `RequiredRoles` is refused for an anonymous caller.
- **`/mentor/approve`, `/mentor/cancel` and `/mentor/session` check the owner.** While a signed-in user's connection is open, an anonymous request gets `401` and another user gets `403`. Send the same token, or the same cookies with `credentials: 'include'`. The owner is the user with the same id: `NameIdentifier`, else `sub`, `oid` or the name. A name never matches the identifier of another user. The owner is checked before the body of a `/mentor/session` request is read.
- **The session snapshot is opaque.** `GET /mentor/session` returns the conversation protected with ASP.NET Core Data Protection and bound to the connection's user. The client cannot read or edit it, and only that user can restore it. On a host without sign-in every visitor is the same anonymous user, so there the snapshot is bound to the application only. To restore after a restart, or on another instance, persist and share the [Data Protection key ring](https://learn.microsoft.com/aspnet/core/security/data-protection/configuration/overview), as you do for authentication cookies.
- **A confirmation raised over SSE has no owner.** Whoever holds its `actionId` can answer it. Keep `/mentor/chat` authenticated and treat the `actionId` as a secret.
- **A confirmation does not outlive its connection.** When the hub connection closes, the turn waiting for the confirmation ends with it. A late `POST /mentor/approve` answers `404` and the action does not run.
- **The dashboard component checks nothing.** `DashboardRole` protects the endpoint, not `MentorDashboard`: put the component on a page with `[Authorize(Roles = "Admin")]`.

What each channel does with roles and confirmations is in [Where nobody can answer](https://docs.mentoragent.net/concepts/governance#where-nobody-can-answer).

## Common errors

- **The hub refuses a valid token with `401`, or treats the user as anonymous.** The server does not read `access_token` from the query string for the hub's path: add the `OnMessageReceived` event.
- **The browser blocks the connection from another origin.** The origin is not in the policy, the policy lacks `AllowCredentials()`, or `UseCors` comes after authorization.
- **Every request to `/mcp` answers 500.** The endpoint has `RequireCors(...)` and the pipeline has no `app.UseCors()`.
- **`/mentor/approve`, `/mentor/cancel` or `/mentor/session` answer `401` or `403` to the right user.** The request does not carry the identity of the hub connection, or the token has none of the claims `NameIdentifier`, `sub`, `oid` or name.
- **`POST /mentor/approve` answers `404`.** Nothing waits for the answer any more: it was answered already, the turn was stopped, or the connection closed. It is not a fault: close the dialog.
- **`GET /mentor/admin/metrics` answers `403`.** The user lacks the `DashboardRole` role, `Admin` unless you change it.
- **At startup the log says that `/mentor/chat` runs a paid turn for anyone who can reach it.** The transports have no `configureTransports: e => e.RequireAuthorization()`.
- **`/mcp` or `/a2a` answers `429`.** The caller went past the allowance of the ASP.NET Core rate limiter. A call to `/mcp` that went past `RateLimitPerUser` does not answer `429`: it answers `200` with an `isError` result.
- **`POST /mentor/session` answers `400`.** The snapshot is not one that this server issued for this user. Possible causes: the snapshot was edited, it belongs to another user, an older version saved it, or the server lost the Data Protection keys that protected it. Save the conversation again with `GET /mentor/session`. The live conversation stays untouched.
- **`POST /mentor/session` answers `415`.** The request does not have a JSON content type.

---

- Previous: [A2A server](https://docs.mentoragent.net/configure/a2a-server.md)
- Next: [Production checklist](https://docs.mentoragent.net/configure/production.md)
