Identity & Access
This is layer 1 — the front door. Before a single line of agent logic runs, before any tool is chosen or any model token is generated, the harness has to answer one question: who is actually calling? That question has four very different answers — a human user, an external MCP client, a peer agent, or a bundle-API caller — and each gets its own proof-of-identity mechanism. Get this layer wrong and every layer behind it is defending an attacker you already let in.
The four callers
Everything in this chapter exists to authenticate one of four kinds of caller. They arrive on different protocols, carry different credentials, and lie in different ways, so the harness treats them separately.
A small, cryptographically signed packet of claims — facts like "who you are"
(sub), "who issued this" (iss), "who it's for"
(aud), and "when it expires" (exp). Because it's signed by a
trusted issuer, the harness can verify it wasn't tampered with or forged without
calling back to the issuer on every request. The whole identity layer rests on
validating these claims strictly.
MCP server authentication (external clients)
The attack this stops: an unauthenticated or forged client connecting to your
MCP server and driving its tools as if it were a trusted agent. The harness's MCP server
exposes real capability — file access, network calls, agent invocation — so its front door is
JWT bearer authentication against Microsoft Entra ID, wired in
src/Content/Infrastructure/Infrastructure.AI.MCPServer/Extensions/McpServerExtensions.cs.
The mechanism is strict token validation. Every connecting client must present a JWT, and the harness checks all four of the claims that matter:
| What is checked | Rule | Why it matters |
|---|---|---|
Issuer (iss) |
Accepts both Entra forms:
https://sts.windows.net/{tenantId}/ and
https://login.microsoftonline.com/{tenantId}/v2.0
|
Confirms the token came from your tenant's identity provider, not an attacker's |
Audience (aud) |
api://{clientId} |
Confirms the token was minted for this API, not borrowed from another app |
| Signature | ValidateIssuerSigningKey = true |
Confirms the token wasn't tampered with or hand-forged |
Lifetime (exp) |
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
|
An expired token is rejected the instant it expires — no grace window |
That last row is the one most systems get wrong. The default clock-skew allowance in most JWT
libraries is five minutes — a token is honored for up to five minutes past its
stated expiry to paper over clock drift between servers. The harness sets
ClockSkew to zero: expiry means expiry.
An attacker captures a valid JWT from network logs or a leaked debug dump. By the time they replay it, the token expired ninety seconds ago. Against a default five-minute clock-skew window, that token still works — the attacker gets a free three-and-a-half-minute replay window on a credential that was supposed to be dead.
Against ClockSkew = TimeSpan.Zero, the replay is rejected outright. The
same setting defeats hand-forged tokens with a future-dated exp but a bad
signature, because signature validation fails first. Strict expiry shrinks the replay
window from minutes to zero.
The MCP server supports three auth modes, selected by McpServerAuthConfig, so a
consumer can match the credential model their callers already have:
X-API-Key) carrying a shared secret. Simplest; fine
for trusted internal callers, weakest for anything internet-facing.
Authorization header. The middle ground.
The deployment-side configuration surface lives under AppConfig at
MCP.Server.Authentication, with Authority, Audience, and
ValidIssuers:
{
"MCP": {
"Server": {
"Authentication": {
"Authority": "https://login.microsoftonline.com/{tenantId}/v2.0",
"Audience": "api://{clientId}",
"ValidIssuers": [
"https://sts.windows.net/{tenantId}/",
"https://login.microsoftonline.com/{tenantId}/v2.0"
]
}
}
}
}
In Development the MCP server requires no auth so you can iterate without
wiring up Entra. In production the harness enforces Entra (or another configured mode)
and throws InvalidOperationException at startup if it
isn't configured — fail-loud, not fail-open. A misconfigured production deployment
refuses to boot rather than silently accepting anonymous callers.
Local development gotcha: DefaultAzureCredential on a VDI
AzureCredentialFactory
(src/Content/Application/Application.Common/Factories/AzureCredentialFactory.cs)
falls back to DefaultAzureCredential whenever an
EntraCredentialConfig doesn't carry a full client-secret or
certificate credential — the normal case for local development, where
DefaultAzureCredential is expected to fall through to your own
Azure CLI or Visual Studio login.
On a corporate VDI (virtual desktop), that assumption can quietly break. The VDI
host itself often has its own managed identity. DefaultAzureCredential
walks a fixed list of credential sources and stops at the first one that succeeds
— and a VDI-provisioned managed identity succeeds before your own CLI or
interactive login ever gets a turn. The app boots and runs normally, but every
call authenticates as the VDI host, not as you.
If you have real access to a resource via your own Entra role assignments
but still get 403 Forbidden when running locally on a VDI,
suspect this before suspecting your role assignments. Check the
Azure.Identity log category — the harness surfaces
DefaultAzureCredential credential selected: {CredentialType}
at Information level by default (via
AzureIdentityDiagnosticsExtensions.AddAzureIdentityDiagnostics),
so you no longer need full Azure SDK EventSource tracing to see which
credential won. If it reports
ManagedIdentityCredential when you expected your own login,
that's the VDI host's identity, not yours.
Workaround: set
ExcludeManagedIdentityCredential on the relevant
EntraCredentialConfig section — for example, in
appsettings.Development.json — to skip the VDI's managed identity
and let the chain reach your own credential. Every consumer of
AzureCredentialFactory embeds an EntraCredentialConfig
the same way; AIFoundryConfig.Entra is one example:
{
"AppConfig": {
"AI": {
"AIFoundry": {
"Entra": {
"ExcludeManagedIdentityCredential": true
}
}
}
}
}
Agent-to-agent (A2A) authentication
The attack this stops: one agent impersonating another to inherit its authority — claiming to be the "admin agent" to get a tool call approved, or spoofing a peer's identity in a multi-agent workflow. A2A means agents calling other agents, and the harness treats in-process and cross-process calls very differently.
In-process calls — one agent invoking another inside the same process — trust
IAgentExecutionContext.AgentIdentity, an AsyncLocal scope that flows
ambiently with the call. There's no network boundary to cross and no attacker between the two
agents, so the in-memory identity is authoritative.
Cross-process calls cross a real network boundary, so they earn no trust by
default. The provider at
src/Content/Infrastructure/Infrastructure.AI/A2A/CrossProcessA2AAuthenticationProvider.cs
requires a peer to clear four checks:
Ordinary HTTPS proves the server's identity to the client. Mutual TLS also makes the client present a certificate, so both ends prove who they are before any application data flows. In the harness this happens at the Kestrel listener — the web server — before the harness code even runs. A peer with no valid client certificate never reaches the agent.
| # | Check | What it proves |
|---|---|---|
| 1 | mTLS peer certificate | Validated at the Kestrel listener before the harness runs — the caller is a known machine on the network |
| 2 | JWT validation | Signature, issuer, audience, and expiry all check out — the caller's claims are genuine |
| 3 | sub overrides the envelope |
The JWT sub claim becomes the authoritative caller id, overriding
whatever the message body declared
|
| 4 | Envelope must match sub |
The envelope's callerAgentId must equal the JWT sub;
mismatch is rejected with a2a.auth_rejected
|
Checks 3 and 4 are the anti-spoofing core, and they're worth pausing on. A message envelope is
data the caller controls — an agent can write any callerAgentId it likes
into the body. The JWT sub claim is cryptographically signed by the
issuer and cannot be forged. So the harness never trusts the envelope's claimed identity:
it takes the signed sub as the real id, then demands the envelope agree with it.
An agent that signs in as worker-7 but stamps admin-agent in the
envelope is rejected — the signed claim wins, and the contradiction is treated as an attack.
The JWT checks themselves are performed by IA2ATokenValidator against
A2ASurfaceConfig: iss against ExpectedIssuer,
aud against ExpectedAudience, exp with
ClockSkewSeconds, plus a revocation check if one is configured.
Authorization: roles vs permissions
Authentication answered "who are you?". Authorization answers "are you allowed to do this?". The harness has two complementary models, and the distinction matters: roles are about who you are ("Admin", "Manager"); permissions are about what specific capability you hold ("Access", "Admin"). Most commands gate on roles; finer-grained API surfaces gate on permissions.
Role-based authorization
Commands carry an attribute like
[Authorize(Roles = "Admin,Manager", Policy = "…")], defined in
src/Content/Application/Application.Common/Attributes/SecurityAttributes/AuthorizeAttribute.cs
and enforced as a MediatR pipeline behavior in
src/Content/Application/Application.Common/MediatRBehaviors/AuthorizationBehavior.cs.
Enforcing it in the pipeline means every command passes through the same gate — there's
no way to call a handler and skip the check.
The combining logic has a precise shape that's easy to get backwards, so here it is explicitly:
| Construct | Combining rule | Reads as |
|---|---|---|
Roles within one [Authorize] |
OR | Any one of the listed roles passes |
Multiple [Authorize] attributes |
AND | Every attribute must pass |
| Policies across attributes | AND | All policies must be satisfied |
So [Authorize(Roles = "Admin,Manager")] on its own means "Admin or
Manager." Stacking a second [Authorize(Roles = "Auditor")] on top tightens it to
"(Admin or Manager) and Auditor." Concrete endpoints behind this gate include
AgentsController, MetricsController, and
DocumentsController — and notably the Prometheus scraping endpoint requires
authorization too, so your metrics aren't an unauthenticated information leak.
Permission-based authorization
For API surfaces that need capability-level control, the attribute at
src/Content/Infrastructure/Infrastructure.APIAccess/Auth/Attributes/PermissionAuthorizeAttribute.cs
encodes permission enums as a policy name that ASP.NET Core's auth middleware parses and
enforces. Written as
[PermissionAuthorize(AuthPermissions.Access, AuthPermissions.Admin)], it requires
the caller to hold all listed permissions — the combining rule here is
AND, not OR.
These two attributes look similar but combine their lists oppositely. Listing several
roles in one [Authorize] widens access (any one passes). Listing
several permissions in [PermissionAuthorize] narrows it (all
required). Reading one as if it followed the other's rule is a real source of
accidental over- or under-permissioning.
SignalR hub authentication (human users)
The problem: human users connect over WebSockets, and a WebSocket upgrade
handshake cannot carry a custom Authorization header — the browser API
simply doesn't allow setting one on the upgrade request. So the standard "put the bearer token
in the Authorization header" pattern is unavailable for the one caller type that needs it most.
The solution, wired in
src/Content/Presentation/Presentation.AgentHub/DependencyInjection.cs: the client
sends the bearer token as an access_token query-string parameter on the connection
URL. A custom OnMessageReceived JWT-bearer event reads it off the query string and
sets context.Token, so the rest of the JWT validation pipeline runs exactly as it
would for a header-borne token. The /hubs/agent hub is authenticated, and every
hub method requires a valid token — there is no anonymous hub surface.
Passing a token in the URL feels wrong if you're used to headers, but it's the documented SignalR pattern precisely because the upgrade request can't hold a header. The token still rides an encrypted TLS connection, and the same strict validation applies. The thing to watch is server access logs — make sure they don't log full query strings, or the token lands in plaintext logs.
CORS: which origins may even ask
What CORS stops: a malicious website your user happens to also have open from
making authenticated requests to your harness in the user's browser. CORS
(Cross-Origin Resource Sharing) is the browser's rule for which web origins are allowed to call
your API at all. The harness configures a named policy, "AgentHubCors", in
src/Content/Presentation/Presentation.AgentHub/DependencyInjection.cs:
{
"AgentHub": {
"Cors": {
"AllowedOrigins": [
"https://app.example.com",
"https://admin.example.com"
]
}
}
}
Allowed origins come from AgentHub:Cors:AllowedOrigins — an explicit allowlist,
never a wildcard in production. AllowAnyMethod() and AllowAnyHeader()
are set, but AllowCredentials() is deliberately omitted. That's a
considered choice, not an oversight: the harness authenticates with bearer tokens, not cookies,
so there are no ambient credentials for the browser to attach. Enabling
AllowCredentials() would buy nothing and would force a stricter origin model than
necessary.
One ordering detail matters: UseCors runs before UseAuthentication
in the pipeline, so a browser's preflight OPTIONS request gets a clean CORS answer
instead of a confusing 401 from the auth layer.
HTTP security headers
What these stop: a family of browser-side attacks — clickjacking, MIME
sniffing, downgrade to HTTP, cross-site scripting. The
SecurityHeadersMiddleware (referenced at
Infrastructure.Common/Middleware/Security/SecurityHeadersMiddleware.cs) sets the same
defensive headers on every response, so there's no endpoint that forgets them.
| Header | Value | Attack it blocks |
|---|---|---|
X-Frame-Options |
DENY |
Clickjacking — your UI embedded in a hostile <iframe> |
X-Content-Type-Options |
nosniff |
MIME sniffing — the browser guessing a file is executable script |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Protocol downgrade — forces HTTPS for a year, subdomains included |
Content-Security-Policy |
restrictive default-src 'self' |
XSS — only same-origin resources may load by default |
Referrer-Policy |
strict-origin-when-cross-origin |
Leaking full URLs (and any tokens in them) to other sites |
Permissions-Policy |
camera / microphone / geolocation disabled | Silent abuse of device hardware |
Strict-Transport-Security is enabled in production via app.UseHsts()
and skipped in Development, where you're typically on plain
http://localhost. For the deployment-side view of these controls — TLS
termination, private networking, the WAF — see the Architecture guide's
Networking & Security page.
Rate limiting
What this stops: a single authenticated caller — compromised, buggy, or abusive — flooding the agent with expensive LLM turns and either running up your bill or starving everyone else. Authentication proves who you are; it doesn't cap how much you can do. That's what rate limiting is for.
The hub-side limiter lives at
src/Content/Presentation/Presentation.AgentHub/Hubs/HubRateLimitFilter.cs. It's a
SignalR IHubFilter, which means it intercepts at the invocation boundary —
before any LLM turn is dispatched, so a throttled call costs nothing. The mechanism is
a partitioned token-bucket limiter keyed by user id: the partitioning is what keeps one user's
burst from starving another, because every user gets their own independent bucket.
Only the expensive, LLM-driving methods are throttled — SendMessage,
RetryFromMessage, EditAndResubmit, and
InvokeToolViaAgent. Cheap lifecycle and read-only methods pass unthrottled, so
rate limiting never gets in the way of, say, loading conversation history. A throttled call
surfaces to the client as a HubException. HTTP (non-SignalR) endpoints are covered
separately by app.UseRateLimiter().
The default bucket holds 10 tokens (the allowed burst) and replenishes 10 tokens per 60-second window. In plain terms: a user can fire off up to ten agent turns in a quick burst, then settles to roughly ten per minute. If your users legitimately run long, fast-iterating sessions, raise the capacity; if you're worried about cost on a public deployment, lower it. Tune the burst (capacity) and the steady rate (replenishment) independently — they answer different questions.
It's tempting to read "auth is disabled in development" and worry the harness ships
open. It doesn't. Auth is bypassed only when both conditions hold at
once: IsDevelopment() is true and the Auth:Disabled
config flag is true. Either one being false — which is the case in any non-development
environment — and authentication is fully enforced. There is no single switch that
opens production.
What this layer does and doesn't cover
Identity is layer 1, and its job is narrow on purpose: it proves who is calling and whether they're allowed through the front door at all. That's the whole scope. It says nothing about what an authenticated, authorized caller may then do.
A perfectly authenticated admin can still ask the agent to take a destructive action it shouldn't auto-approve — that's the job of layer 2, Autonomy & Governance, which decides how much an agent may do without a human. A perfectly authenticated caller can still trigger a tool that tries to reach an internal service — that's Egress & SSRF Defense. Identity is the necessary first gate, but every layer behind it assumes the caller is already known and still does its own job. Defense in depth means no single layer is trusted to be the only one holding.