Authentication & security
There are two independent layers, and both are enforced: the MCP endpoint itself, and every individual tool call replayed through your pipeline.
The two layers
The MCP endpoint. RequireAuthorization = true makes /mcp
itself require an authenticated caller, optionally against a named policy
(AuthorizationPolicy) and specific schemes (AuthenticationSchemes).
Unauthenticated callers get a challenge; authenticated ones without the policy get a forbid.
Each tool call. Because the synthetic request traverses the real pipeline, the
target action's own [Authorize], policies, roles, claims and custom filters run
untouched. Nabu does not interpret, cache or shortcut them.
How identity reaches the action
Identity reaches the action two ways, which reinforce each other:
- Credentials-bearing headers (
Authorization,Cookie, tracing headers, and anything you add toForwardedHeaders/ForwardedHeaderPrefixes) are copied onto the synthetic request, so your authentication middleware re-authenticates it normally. - The
ClaimsPrincipalestablished for the MCP request is seeded onto the synthetic context, so schemes whose credentials cannot be replayed from headers alone still work. Authentication middleware overwrites it whenever the forwarded credentials authenticate successfully. Disable withPropagateUser = false.
Hop-by-hop and content headers (Content-Length, Transfer-Encoding,
Accept-Encoding, Host, ...) are never forwarded; they are rebuilt for the
synthetic request.
Protected headers
A model-supplied argument can never override the headers that carry credentials or proxy metadata.
Even with ExposeHeaderParameters on, a tool argument that binds to a header in
ProtectedHeaders — by default Authorization, Cookie,
Host, X-Forwarded-For, X-Forwarded-Proto and
X-Forwarded-Host — is dropped with a warning, and the value forwarded from the MCP
caller stays in place. Constants pinned by the developer through ConstantParameters
are exempt, because they are developer input rather than model input. Removing a name from the set
is the explicit opt-in:
options.ProtectedHeaders.Remove("Authorization"); // only if you really mean it
Recommended production configuration
The defaults are tuned for a first run: the MCP endpoint is anonymous and every discovered tool is advertised, while each invocation is still authorized by the application pipeline. That is never an authorization bypass — a caller can at most see the names and schemas of tools it cannot invoke — but for production deployments, lock the endpoint down and tailor the catalogue to the caller:
options.RequireAuthorization = true; // the endpoint itself challenges anonymous callers
options.ToolVisibility = McpToolVisibility.Authorized; // advertise only what the caller may actually invoke
Advertising tools per caller
By default every discovered tool is advertised to everyone, and a caller that invokes one it is not allowed to use gets the action's own 401 or 403 back as a tool error. That is safe, but it hands the model a menu it cannot order from — and a client added before anyone has signed in sees the whole catalogue.
ToolVisibility tailors tools/list to whoever is asking:
| Value | tools/list contains |
|---|---|
All (default) | Every discovered tool, whoever is asking. |
Authenticated | Tools whose actions need authorization, only once the caller is authenticated. |
Authorized | Tools whose actions need authorization, only once the caller satisfies their policies. |
Nabu reads the requirement during discovery, from the [Authorize] and
[AllowAnonymous] metadata of the action, its controller and the filter collection, and
resolves it the way AuthorizationMiddleware would for a real request:
[AllowAnonymous]on the action or the controller wins outright, exactly as it does in MVC.[Authorize]— with a policy, roles, or schemes — is combined into a single policy, including a globally registeredAuthorizeFilter.- An action carrying neither is not assumed to be public:
AuthorizationOptions.FallbackPolicyapplies to it, so in a secure-by-default application every action is protected until an[AllowAnonymous]opts it out, and the tool list says the same.
The resolved policy is then evaluated against the caller with the application's own
IAuthorizationPolicyProvider and IAuthorizationService. An
unauthenticated caller is therefore shown only the tools that need no authorization; the rest
appear when it lists the tools again with credentials.
Nothing MCP-specific is involved: the attributes that already secure the API are the ones that
decide, so an action opts out of its controller's [Authorize] the same way it always
has.
[ApiController]
[Route("api/todos")]
[Authorize] // the controller is protected...
public class TodosController : ControllerBase
{
/// <summary>Lists the priority levels a todo item can be given.</summary>
[HttpGet("priorities")]
[AllowAnonymous] // ...and this one action is not
[McpTool]
public ActionResult<IEnumerable<string>> GetPriorities() => ...
}
todos_get_priorities is advertised to, and callable by, a caller holding no token;
every other todo tool waits until it signs in.
When authorization depends on something no attribute expresses, take the decision over entirely:
services.AddSingleton<IMcpToolAuthorizationEvaluator, MyEvaluator>();
Adding a client before it has credentials
RequireAuthorization = true makes /mcp itself reject anonymous callers,
which is what triggers the OAuth flow in MCP clients that support one — but it also means a client
cannot so much as initialize until credentials exist. AnonymousAccess opens a narrow
door in that gate:
| Value | An unauthorized caller may |
|---|---|
None (default) | Nothing. Every request is challenged. |
Discovery | initialize, ping and the listing methods. tools/call is still challenged. |
AnonymousTools | The above, plus tools/call for tools whose actions need no authorization. |
options.RequireAuthorization = true;
options.AnonymousAccess = McpAnonymousAccess.AnonymousTools;
options.ToolVisibility = McpToolVisibility.Authorized;
Now a client added without credentials connects, lists the public tools and can use them;
everything else is a 401, which is exactly the signal a client needs to start authenticating; and
once it does, the next tools/list returns the full set it is entitled to. Pair the two
options as above — AnonymousAccess on its own would advertise tools the anonymous
caller cannot call.
The door is only open to callers that hold no credentials. One that presents a token which is rejected is challenged as before, so an expired or malformed token is never quietly downgraded to the anonymous tool list.
AuthorizationOptions.FallbackPolicy, mount
UseNabuMcp() before UseAuthorization(). A fallback
policy applies to every request that matches no endpoint, and the MCP endpoint is middleware rather
than an endpoint, so authorization would otherwise challenge it before Nabu saw it —
AnonymousAccess included. Tool calls are unaffected either way: they traverse the
whole pipeline and meet the fallback policy at the action.
Because the server is stateless it cannot push notifications/tools/list_changed, so a
client that caches the tool list should re-list after authenticating.
[McpTool] is opt-in for exactly this reason, and
[McpIgnore] lets you keep an action reachable over HTTP while hiding it from MCP.