Chapter 02 · Access Control

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.

Human users
A person driving the agent through the web UI. They connect over a WebSocket (SignalR) and prove identity with a bearer token issued by your identity provider. See SignalR hub authentication.
External MCP clients
Other systems (or other agents) connecting into this harness's MCP server to use the tools it exposes. They authenticate with a JWT bearer token validated against Entra ID. See MCP server authentication.
Peer agents (A2A)
Other agents calling this agent — agent-to-agent, or A2A. In-process peers are trusted by execution context; cross-process peers must present a client certificate and a JWT. See Agent-to-agent authentication.
Bundle API callers
Systems that upload a zipped agent over REST and ask the harness to run it. They present an Entra JWT, and — uniquely among the four — the token does more than let them in: it selects the capability envelope that caps what the uploaded agent may do. (That is host-side configuration keyed by your identity — not to be confused with the A2A message envelope in Agent-to-agent authentication below, which is untrusted caller-supplied data.) This surface is off by default and fails closed when half-configured. See Onboarding · Ch. 17 Execution API and Autonomy & Governance.
JWT (JSON Web Token)

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.

Attack scenario: the stolen, just-expired token

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:

ApiKey
A custom header (default X-API-Key) carrying a shared secret. Simplest; fine for trusted internal callers, weakest for anything internet-facing.
Bearer
A token in the standard Authorization header. The middle ground.
Entra
Full OAuth 2.0 client-credentials flow against Microsoft Entra ID — the production-grade mode, with all four claim checks above.

The deployment-side configuration surface lives under AppConfig at MCP.Server.Authentication, with Authority, Audience, and ValidIssuers:

json
{
  "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"
        ]
      }
    }
  }
}
!
Development disables auth — production must not

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.

!
Symptom: 403s that look like a permissions bug

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:

json
{
  "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:

mTLS (mutual TLS)

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.

!
Roles list is OR; permissions list is AND

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.

i
Query-string tokens are normal for WebSockets

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:

json
{
  "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().

Operator tip: the default budget and how to tune it

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.

!
The dev auth bypass is double-gated — production is not open

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.