Secure the endpoints
Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
On this page
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
Section titled “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 the widget talks through the circuit, which your app's authentication already covers: only the endpoints of the MCP server and the A2A server are left, if you map them.
What each endpoint exposes
Section titled “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
Section titled “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:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerMinimal configuration
Section titled “Minimal configuration”Choose the authentication scheme
Section titled “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:
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, JwtBearerEventsbuilder.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
Section titled “Require authorization on each endpoint”After Build(), with MentorAgent and AddMentorAgentServer() registered as on the other pages:
// 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 cardconfigureTransportscovers the hub and both/mentor/chatendpoints, the three that run a turn.- MCP and A2A are mapped by their own calls, so
configureTransportsdoes not reach them. Theirconfigurecallback does, and for A2A it covers the card too. - Leave out the lines for the endpoints you do not map.
MapMentorAgentMcp()andMapMentorAgentA2A()also needAddMentorAgentMcp()andAddMentorAgentA2A().AddMentorAgentServer()calls both.
Then send the credentials from each client: accessTokenFactory in a JavaScript client, AccessTokenProvider in a WebAssembly client, and the Headers of a RemoteAgents entry in another MentorAgent host.
Complete configuration
Section titled “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:
// 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
UseCorsbeforeUseAuthenticationandUseAuthorization, 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/mentorendpoints,/a2aand/mcp. A browser client of/mcpalso needs theMcp-Session-Idheader exposed: MCP server shows a policy of its own for that endpoint. - A limit for programs.
RateLimitPerUsercounts 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
Section titled “Options that matter here”| Option | Why it matters for the endpoints |
|---|---|
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, 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, MemoryAutoCapture | With either on and /mentor/chat open to anyone, MentorAgent logs a warning at startup |
DashboardRole | The role that GET /mentor/admin/metrics requires. An empty value opens it: development only |
McpCallerPrincipal | Which role-gated actions an authenticated MCP caller sees |
MaxMessageLength | Longer messages are refused before the model is called |
Every option is in Configuration options, and the limits under Security and limits.
Security and roles
Section titled “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 theNameIdentifierclaim, elsesub,oidor the name.RequiredRolesis checked withIsInRole. With JWT bearer, the default claim mapping turns the token'ssubandroleclaims intoNameIdentifierandClaimTypes.Role. WithMapInboundClaims = false, the roles needTokenValidationParameters.RoleClaimType: see MCP server. - Apps with Razor components.
AddRazorComponents()andAddMentorAgentServer()can come in either order. MentorAgent reads the user of the hub, of/mentor/chatand 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
RequiredRolesis refused for an anonymous caller. /mentor/approve,/mentor/canceland/mentor/sessioncheck the owner. While a signed-in user's connection is open, an anonymous request gets401and another user gets403. Send the same token, or the same cookies withcredentials: 'include'. The owner is the user with the same id:NameIdentifier, elsesub,oidor the name. A name never matches the identifier of another user. The owner is checked before the body of a/mentor/sessionrequest is read.- The session snapshot is opaque.
GET /mentor/sessionreturns 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 (external site), as you do for authentication cookies. - A confirmation raised over SSE has no owner. Whoever holds its
actionIdcan answer it. Keep/mentor/chatauthenticated and treat theactionIdas 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/approveanswers404and the action does not run. - The dashboard component checks nothing.
DashboardRoleprotects the endpoint, notMentorDashboard: put the component on a page with[Authorize(Roles = "Admin")].
What each channel does with roles and confirmations is in Where nobody can answer.
Common errors
Section titled “Common errors”- The hub refuses a valid token with
401, or treats the user as anonymous. The server does not readaccess_tokenfrom the query string for the hub's path: add theOnMessageReceivedevent. - The browser blocks the connection from another origin. The origin is not in the policy, the policy lacks
AllowCredentials(), orUseCorscomes after authorization. - Every request to
/mcpanswers 500. The endpoint hasRequireCors(...)and the pipeline has noapp.UseCors(). /mentor/approve,/mentor/cancelor/mentor/sessionanswer401or403to the right user. The request does not carry the identity of the hub connection, or the token has none of the claimsNameIdentifier,sub,oidor name.POST /mentor/approveanswers404. 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/metricsanswers403. The user lacks theDashboardRolerole,Adminunless you change it.- At startup the log says that
/mentor/chatruns a paid turn for anyone who can reach it. The transports have noconfigureTransports: e => e.RequireAuthorization(). /mcpor/a2aanswers429. The caller went past the allowance of the ASP.NET Core rate limiter. A call to/mcpthat went pastRateLimitPerUserdoes not answer429: it answers200with anisErrorresult.POST /mentor/sessionanswers400. 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 withGET /mentor/session. The live conversation stays untouched.POST /mentor/sessionanswers415. The request does not have a JSON content type.
See also
Section titled “See also”- Roles, confirmation and governance — How MentorAgent checks roles and asks for confirmation before an action runs, on every channel, and what it leaves to your code.
- Any front end: React, Angular, Vue — Use MentorAgent from React, Angular, Vue, a mobile app or any HTTP client: SignalR or Server-Sent Events, confirmation over HTTP, and how to stop an answer.
- MCP server — Expose your actions to MCP clients such as Claude Desktop or VS Code: authentication, CORS, and the actions that the server does not list.
- Configuration options — Every MentorOptions property with its type, default and effect, grouped by area, plus the options of the WebAssembly client and the YAML loader.
