Skip to content

Authentication

AuthProxy supports three authentication modes that can be active simultaneously:

  • Interactive browser sessions – OpenID Connect (OIDC) with a cookie.
  • Machine-to-machine / API – JWT Bearer tokens from an external identity provider.
  • Back-channel client credentials – service-owned client credentials verified by the target service itself.

Configure one or more OpenID Connect providers under Cratis:AuthProxy:Authentication:OidcProviders.

Single provider – the auth proxy challenges unauthenticated browser requests directly with that provider:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>"
}
]
}
}
}
}

Multiple providers – the auth proxy redirects unauthenticated browser requests to a built-in provider-selection page (/.cratis/select-provider) so the user can choose which provider to log in with:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"Scopes": []
},
{
"Name": "Google",
"Type": "Google",
"Authority": "https://accounts.google.com",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"Scopes": []
}
]
}
}
}
}

Each provider generates a dedicated login endpoint at /.cratis/login/{scheme}. The scheme name is derived from the provider Name by lowercasing and replacing spaces with hyphens (e.g. "My Provider" → /.cratis/login/my-provider).

Both behaviors above — the direct challenge and the selection page — apply to browser navigations. A caller that is not navigating to a page is refused with 401 instead, so the rejection is visible to a client that checks the status code. See Unauthenticated responses.

When authentication starts from a tenant-scoped request, AuthProxy stores tenant resolution metadata in the protected authentication state value:

  • Tenant ID
  • Tenant resolution strategy
  • Strategy-specific metadata (for SubHost, the configured ParentHost)

On callback (/signin-{scheme}), AuthProxy reads this state and re-applies strategy behavior before finishing sign-in. For SubHost, AuthProxy reconstructs the tenant URL and redirects back to that tenant host.

Example flow:

  1. Request arrives at https://some-tenant.cratis.studio/
  2. AuthProxy resolves tenant some-tenant via SubHost
  3. Challenge is sent with protected state containing tenant metadata
  4. Provider redirects back to https://auth.cratis.studio/signin-github?...&state=...
  5. AuthProxy restores tenant metadata from state
  6. AuthProxy redirects to https://some-tenant.cratis.studio/ (original return URL preserved)

This allows a common callback endpoint while still restoring tenant-specific behavior after sign-in.

PropertyTypeDescription
NamestringDisplay name shown on the login selection page.
TypestringProvider type hint (Microsoft, Google, or Custom).
AuthoritystringOIDC authority URL.
ClientIdstringOAuth 2.0 client ID.
ClientSecretstringOAuth 2.0 client secret. Leave empty when ClientCredential selects a certificate or federated credential.
ClientCredentialobjectOptional certificate or federated credential used instead of ClientSecret. See Client credentials.
Scopesstring[]Additional scopes to request (beyond openid, profile, email).
ResponseModestringHow the provider returns the authorization code: Query (default) or FormPost. See below.

The handshake only completes when the provider callback carries the correlation cookie, and that cookie is SameSite=Lax — which a browser attaches to a top-level GET and withholds from a cross-site POST. The default Query response mode therefore has the provider return the authorization code in a top-level GET redirect, and the handshake cookies stay Lax.

Some providers mandate a form POST callback — Apple whenever the name or email scopes are requested. Setting ResponseMode to FormPost opts that provider into it, which also switches that provider’s correlation and nonce cookies to SameSite=None; Secure — a cross-site POST only ever carries None cookies, and None requires HTTPS. Do not choose FormPost for providers that support Query; it trades away the Lax hardening for nothing.

Client credentials: certificates and federated credentials

Section titled “Client credentials: certificates and federated credentials”

By default AuthProxy authenticates to a provider’s token endpoint with ClientSecret. Many organizations disallow long-lived client secrets, and Microsoft recommends certificates or workload identity federation for Microsoft Entra ID confidential clients. Set ClientCredential on the provider to authenticate with a client_assertion (RFC 7523) instead. Leave ClientSecret empty: AuthProxy refuses to start when both are configured.

AuthProxy presents the credential during authorization-code redemption at the provider’s token endpoint and in pushed authorization requests at the provider’s PAR endpoint when the provider supports them. The credential loaders come from Microsoft.Identity.Web, so certificate stores, Key Vault, workload identity and managed identity behave as they do in any other Microsoft.Identity.Web application.

SourceCredentialRequired properties
ClientSecretClientSecret sent as client_secret (the default).—
CertificateFileA PKCS#12 (.pfx) file with the private key signs the assertion.CertificatePath; CertificatePassword when the file has one.
CertificateStoreA certificate found by thumbprint in a certificate store signs the assertion.CertificateThumbprint; CertificateStorePath defaults to CurrentUser/My.
KeyVaultCertificateA certificate downloaded from Azure Key Vault signs the assertion.KeyVaultUrl (https), KeyVaultCertificateName.
FederatedTokenFileA platform-issued federated token read from a file is the assertion (Kubernetes workload identity).TokenFilePath, or the AZURE_FEDERATED_TOKEN_FILE environment variable.
ManagedIdentityAn Azure managed identity token for the token-exchange audience is the assertion.None. ManagedIdentityClientId selects a user-assigned identity.

A certificate assertion is a short-lived JWT signed with RS256 (RSA keys), or ES256, ES384 or ES512 (ECDSA P-256, P-384 or P-521 keys respectively). Its issuer and subject are ClientId, its audience is the provider’s token endpoint, and its header carries the certificate thumbprint (x5t). Upload the certificate’s public part to the app registration. AuthProxy loads a certificate once and loads it again after it expires. For CertificateFile or KeyVaultCertificate, put the renewed certificate in the same file or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. If the replacement is still expired, sign-in fails and AuthProxy retries loading at most once per minute. For CertificateStore, renewal changes the thumbprint: update CertificateThumbprint to the new certificate’s thumbprint and restart AuthProxy.

KeyVaultCertificate authenticates to Key Vault with the default Azure credential chain. Set ManagedIdentityClientId (or AZURE_CLIENT_ID) to use a user-assigned managed identity. The identity needs both certificate-get and secret-get permissions (for example, the Key Vault Certificate User and Key Vault Secrets User roles), because the loader reads the certificate and the secret containing its private key. The certificate must have an exportable private key; a non-exportable Key Vault certificate cannot sign client assertions in AuthProxy.

FederatedTokenFile and ManagedIdentity need a federated identity credential on the app registration that trusts the platform issuer: the cluster’s OIDC issuer and service account for workload identity, or the managed identity. ManagedIdentity requests its token for api://AzureADTokenExchange (or the national-cloud equivalent resolved from Authority). Set TokenExchangeAudience to override it.

Certificate from Key Vault:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientCredential": {
"Source": "KeyVaultCertificate",
"KeyVaultUrl": "https://<vault-name>.vault.azure.net",
"KeyVaultCertificateName": "authproxy-client"
}
}
]
}
}
}
}

Managed identity on Azure Container Apps or App Service:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientCredential": {
"Source": "ManagedIdentity",
"ManagedIdentityClientId": "<user-assigned-identity-client-id>"
}
}
]
}
}
}
}

With environment variables, the same settings are Cratis__AuthProxy__Authentication__OidcProviders__0__ClientCredential__Source=ManagedIdentity and so on.

If the credential cannot be loaded or produces no assertion during authorization-code redemption, the sign-in is handled as a failed sign-in. AuthProxy logs credential-loading and assertion-provider errors with the provider and credential source. A credential failure during a pushed authorization request happens while starting the sign-in challenge, outside the callback’s failed-sign-in handling, and returns an HTTP 500 response instead. OAuth 2.0 providers (below) still authenticate with ClientSecret only.

Provider registrations can opt into a stable, provider-aware account tuple. Without this section, AuthProxy preserves its legacy claim-selection and payload behavior, except that the reserved urn:cratis:identity:* namespace is always removed from fresh legacy callbacks before session storage.

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft Entra",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"CanonicalIdentity": {
"ProviderKey": "entra-workforce",
"SubjectClaimType": "oid"
}
}
]
}
}
}
}
PropertyApplies toContract
InvitationCompletionEnabledOIDC and OAuthOpts this provider into signed invitation completion. Defaults to false; ordinary sign-in remains available. Enable only when the provider can produce the exact verified email evidence below.
InvitationIdentityBindingCompletionEnabledOIDC and OAuthOpts this provider into signed invitations already bound by the invitation issuer to an immutable provider subject. Defaults to false. This mode does not infer email ownership; enable it only for a tenant-scoped immutable subject and framework-validated issuer.
ProviderKeyOIDC and OAuthStable lowercase ASCII key, independent of display name and authentication scheme. Keys must be unique across configured providers.
SubjectClaimTypeOIDC and OAuthExact claim type on the resulting authenticated ClaimsPrincipal that supplies the subject. Exactly one nonempty value is required, and the claim type must be outside the entire case-insensitive urn:cratis:identity:* namespace reserved for AuthProxy-authored metadata. There is no fallback to sub, name, username, or email. For OAuth user-info fields, map the raw JSON field to this principal claim with ClaimMappings.
EmailClaimTypeOIDC and OAuthExact provider-derived claim used for a signed invitation attestation. Defaults to email; exactly one address-shaped value is required during invitation completion.
EmailVerifiedClaimTypeOIDC and OAuthExact provider-derived boolean claim proving ownership of EmailClaimType. Defaults to email_verified; only one value equal to true is accepted.
AssuranceClaimTypeOIDC and OAuthExact provider-derived assurance claim. Defaults to acr; it must be present exactly once for invitation completion.
IssuerOAuth onlyExplicit absolute HTTPS issuer assigned to the authenticated user-info flow. OIDC providers must omit it because AuthProxy uses the issuer from the framework-validated OIDC token.

An identity-bound invitation carries both recipient_provider_key and recipient_identity_binding in the signed invitation capability. AuthProxy accepts only one exact 43-character base64url SHA-256 binding, restricts provider selection to the exact canonical provider key, and completes only through a provider explicitly opted into InvitationIdentityBindingCompletionEnabled. AuthProxy treats the binding as opaque: the invitation authority must independently recompute and compare it from the attested provider key, validated issuer, and immutable subject. For Microsoft Entra, use a tenant-specific authority and SubjectClaimType=oid; never substitute email, preferred_username, or the mutable display name for the object identifier.

For OAuth, SubjectClaimType names the claim after the configured user-info claim actions have run, not the raw JSON property returned by the provider. This complete example maps the raw user-info id field to a principal sub claim and then selects that sub claim as the canonical subject:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OAuthProviders": [
{
"Name": "GitHub Enterprise",
"Type": "GitHub",
"AuthorizationEndpoint": "https://github.example.com/login/oauth/authorize",
"TokenEndpoint": "https://github.example.com/login/oauth/access_token",
"UserInformationEndpoint": "https://github.example.com/api/user",
"VerifiedEmailEndpoint": "https://github.example.com/api/user/emails",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"Scopes": ["read:user", "user:email"],
"ClaimMappings": {
"sub": "id"
},
"CanonicalIdentity": {
"ProviderKey": "github-workforce",
"SubjectClaimType": "sub",
"Issuer": "https://github.example.com"
}
}
]
}
}
}
}

Given a user-info response such as { "id": 12345, "login": "octocat" }, the mapping produces the principal claim sub=12345; canonical resolution reads that resulting sub claim. Without the mapping, setting SubjectClaimType to sub fails closed because no such principal claim exists.

For OAuth providers such as GitHub whose ordinary user-information response can omit private addresses, configure VerifiedEmailEndpoint. AuthProxy calls it with the provider access token and accepts exactly one JSON-array entry whose primary and verified properties are both true and whose email property is nonempty. It replaces any ordinary user-information email with that verified value and writes the configured email-verification claim as true. A malformed, ambiguous, missing, or non-success response establishes no invitation email evidence. Request the provider scope needed by that endpoint (user:email for GitHub).

When a provider supplies no configured assurance claim, AuthProxy records the successfully completed protocol as oidc or oauth in AssuranceClaimType. A provider-supplied value such as OIDC acr takes precedence. These values describe authentication assurance only; they do not grant application roles or membership.

AuthProxy normalizes issuer scheme and host casing, removes a default port and trailing slash, and rejects userinfo, query strings, and fragments. Plain HTTP is accepted only for a loopback development issuer. The stable account key is the complete (providerKey, normalizedIssuer, subject) tuple. A raw subject is not globally unique: two providers can issue the same value, and some providers issue pairwise or client-specific subjects that change when the client registration changes.

For Microsoft Entra workforce accounts, configure SubjectClaimType as oid when the tenant object ID is the intended account identifier. Do not use preferred_username, upn, or email as a subject. Those values are mutable and can be reassigned. For a provider where sub is the intended client-specific identifier, configure sub explicitly.

After a fresh provider callback, AuthProxy removes case-insensitive collisions with its reserved claims and adds exactly one AuthProxy-authored set:

  • urn:cratis:identity:provider-key
  • urn:cratis:identity:issuer
  • urn:cratis:identity:subject

The canonical subject also becomes ClientPrincipal.UserId and therefore the x-ms-client-principal-id value. Provider and issuer travel in the base64 client principal’s claim list; downstream Arc applications receive those claims even though Arc does not preserve the client principal’s identityProvider JSON property. The same tuple is posted by invitation exchange, credential linking, and sign-in notification.

x-ms-client-principal-id is therefore the provider’s raw subject value, not a globally unique account key. Do not key cross-provider data or authorization by that header alone. Consumers that need the stable account identity must use all three reserved canonical claims as the (providerKey, normalizedIssuer, subject) tuple.

A successful canonical browser sign-in binds the authentication cookie to the static provider registration that issued it. AuthProxy writes an opaque, versioned registration fingerprint into the protected authentication ticket for a normal sign-in callback. A credential-link callback does not create or replace a session, so it does not write this fingerprint.

AuthProxy recalculates the fingerprint whenever it validates the cookie. The user must authenticate again if any of these registration inputs changed:

  • Provider protocol (OIDC or OAuth) or derived authentication scheme
  • Canonical ProviderKey or SubjectClaimType
  • Configured client ID or the effective client ID on the named authentication handler
  • For OIDC, Authority or the effective MetadataAddress
  • For OAuth, the normalized configured canonical Issuer; both the configured provider value and the effective named-handler value for AuthorizationEndpoint, TokenEndpoint, and UserInformationEndpoint; every configured ClaimMappings key/value pair; and both configured and effective authorization parameters. Collection order does not affect the fingerprint.

The fingerprint contains no subject, email, claims, tokens, client secret, or other PII or secret input. It is an internal continuity marker, not an account identifier and not a claim forwarded to downstream services.

OIDC issuers are different from static registration. AuthProxy records the issuer from the framework-validated token in each canonical session, but does not include that runtime issuer in the registration fingerprint. An unchanged multi-tenant OIDC registration can therefore accept sessions whose validated issuers differ by tenant. For OAuth, there is no validated ID-token issuer: the explicit canonical Issuer is static configuration, is checked again during cookie validation, and changing it forces reauthentication.

The backward-compatibility carve-out applies only to a true legacy cookie: it has neither a claim in the case-insensitive urn:cratis:identity:* namespace nor a canonical registration fingerprint, and its recorded authentication scheme does not currently resolve to a canonical provider. AuthProxy accepts that cookie without applying canonical continuity. If a marker-free cookie’s recorded scheme now resolves to a canonical provider, AuthProxy rejects it and signs out the cookie session so the user establishes the canonical tuple through a fresh provider callback. A cookie that carries either canonical marker is likewise rejected and signed out when its canonical tuple or fingerprint is missing, malformed, or mismatched.

Canonical identity proves provider authentication metadata only. Applications still decide membership, roles, scopes, and authorization. AuthProxy identity headers are authenticated by deployment topology, not by a per-request signature: prevent clients from reaching downstream services directly. Before forwarding any request, AuthProxy removes all inbound x-ms-client-principal, x-ms-client-principal-id, and x-ms-client-principal-name values. It writes exact trusted replacements only when it has an authenticated principal, so an unauthenticated caller cannot smuggle identity headers downstream.


Not every provider publishes an OpenID Connect discovery document. GitHub is the common one that does not, so it is configured under Cratis:AuthProxy:Authentication:OAuthProviders with its endpoints named explicitly instead of discovered from an authority:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OAuthProviders": [
{
"Name": "GitHub",
"Type": "GitHub",
"AuthorizationEndpoint": "https://github.com/login/oauth/authorize",
"TokenEndpoint": "https://github.com/login/oauth/access_token",
"UserInformationEndpoint": "https://api.github.com/user",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"Scopes": [ "read:user", "user:email" ],
"ClaimMappings": {
"sub": "id",
"name": "name",
"preferred_username": "login",
"email": "email"
},
"AuthorizationParameters": {
"prompt": "select_account"
}
}
]
}
}
}
}

OAuth providers sit alongside OIDC providers in every other respect: they appear on the provider-selection page, they get a login endpoint at /.cratis/login/{scheme}, and the count of both together decides whether an unauthenticated browser is challenged directly or offered a choice.

ClaimMappings is what turns the provider’s user-info JSON into claims — the key is the claim type to create, the value is the field to read it from. The mapping above produces the claims AuthProxy reads when it builds the forwarded principal.

AuthorizationParameters adds provider-specific, static values to each authorization request. The example asks GitHub to show account selection instead of silently reusing the active provider session. Keep request policy here rather than appending it to AuthorizationEndpoint, so the endpoint remains the provider endpoint. AuthProxy rejects keys owned by the OAuth handler, case-insensitively: client_id, scope, response_type, redirect_uri, state, code_challenge, and code_challenge_method. This preserves the framework-generated callback, correlation state, and PKCE values.

PropertyTypeDescription
NamestringDisplay name shown on the login selection page, and the source of the scheme name.
TypestringProvider brand (GitHub, Microsoft, Google, Apple, or Custom). Picks the logo, and for GitHub also enables organization and team claims.
AuthorizationEndpointstringThe OAuth 2.0 authorization endpoint URL.
TokenEndpointstringThe OAuth 2.0 token endpoint URL.
UserInformationEndpointstringThe user-information (profile) API endpoint URL.
ClientIdstringOAuth 2.0 client ID.
ClientSecretstringOAuth 2.0 client secret.
Scopesstring[]Scopes to request. Adding read:org to a GitHub provider also adds organization and team claims to the session.
ClaimMappingsobjectClaim type → user-info JSON field name.
AuthorizationParametersobjectStatic authorization-request parameter → value. Framework-owned OAuth parameters are rejected at startup.

Configuring a provider decides who can sign in, which on a public provider is everybody. To decide who may then get through, see Authorization.


Interactive browser sessions are cookie-based, and every cookie AuthProxy issues for identity or tenant context is session-scoped or short-lived — closing the browser ends them. On top of that, Cratis:AuthProxy:Session bounds what a browser session that never closes may keep:

{
"Cratis": {
"AuthProxy": {
"Session": {
"Lifetime": "12:00:00",
"SlidingExpiration": false,
"CookieName": ".Cratis.AuthProxy.Auth.v2",
"CookieDomain": "",
"TerminateOnIdentityDenial": false,
"IdentityRevalidationInterval": "00:10:00",
"TenantRevalidationInterval": "00:10:00"
}
}
}
}
PropertyDefaultDescription
Lifetime12:00:00Absolute lifetime of the authentication ticket. When it elapses the user must re-authenticate with the identity provider, even in a browser session that never closed.
SlidingExpirationfalseWhether activity extends the ticket lifetime. Disabled by default so Lifetime is a hard bound.
CookieName.Cratis.AuthProxy.Auth.v2Name of the session cookie. Set it to keep two AuthProxy deployments under one parent domain from reading each other’s sessions, or to retire an old cookie. Changing it signs users out once. It has to be a valid cookie name, and the __Host- prefix is refused when CookieDomain is set.
CookieDomain"" (host-only)Domain the session cookie is scoped to, for example .example.com, so one sign-in is honored on every host under it. Empty keeps today’s host-only cookie. See One proxy, several hosts.
TerminateOnIdentityDenialfalseWhether an identity-verification denial ends the local AuthProxy session before serving the forbidden response. This signs out of AuthProxy and clears its session cookies; it does not log the caller out of the external identity provider.
IdentityRevalidationInterval00:10:00How long a resolved authorization is remembered before the identity details — and the authorization they represent — are re-resolved against the services. Zero or negative means no bound: with any Required service (the default) no authorization record is written; when every participating service is BestEffort it falls back to ten minutes. The independent in-memory result cache still applies.
TenantRevalidationInterval00:10:00How long a tenant selected through the tenant-selection flow is trusted before it is re-validated against TenantsEndpoint, so revoked tenant access takes effect without per-request backend calls. Zero or negative disables re-validation.

The authentication cookie itself carries no persistent Expires — the browser drops it when the session ends — and is HttpOnly, SameSite=Lax, and marked Secure whenever the site is served over HTTPS. It belongs to the one host that issued it unless you set CookieDomain.

CookieDomain scopes the cookie to a parent domain so that one sign-in is honored on every host under it. Use the narrowest domain that covers the hosts in Services:<name>:Hosts: the cookie also goes to hosts that are not behind AuthProxy. AuthProxy refuses to start when a declared host is outside the domain, when the domain is an IP address, a top-level domain, a wildcard or carries a port, and when a tenant resolution uses the SubHost strategy. It warns when AllowedHosts accepts every host. Browsers that still hold an earlier host-only cookie have it deleted on sign-in and sign-out. The full walk-through, including the single callback host SignIn:CallbackHost, is in One proxy, several hosts. Re-validation is cached in memory per instance, so within an interval no extra backend calls are made; when the interval lapses, a single backend round-trip refreshes the cached identity or tenant context.

The resolved identity is written to two cookies, and the split is a security boundary rather than an implementation detail:

CookieReadable by scriptContentsRole
.cratis-identityYesBase64 JSON identity detailsLets a frontend render the signed-in user without a round-trip. Never treated by AuthProxy as evidence of anything.
.cratis-identity-authorizationNo (HttpOnly)A sealed, unforgeable recordCarries the authorization decision that is allowed to skip the /.cratis/me call on later requests.

Because .cratis-identity is deliberately script-readable, anything a client can write must not decide authorization — so the decision lives in the sealed cookie instead. It is protected with ASP.NET data protection and bound to the user and tenant it was issued for, and its expiry is carried inside the sealed value rather than left to the cookie’s Max-Age, which a non-browser client is free to ignore. A record that cannot be unsealed (for example after a data-protection key rotation) is not a failure: the caller is simply re-authorized against the services.

AuthProxy still reads unexpired version-one authorization records issued for legacy identities. That legacy format separated the expiry, raw subject, and tenant with |, so compatibility is limited to presented legacy subjects and tenant IDs that do not contain that delimiter. A delimiter-bearing value is rejected and the caller is re-authorized. Current version-two records use structured fields and do not inherit this legacy restriction.

Deployments running more than one AuthProxy instance should configure a shared Data Protection key store so a record sealed by one instance can be read by the others; without it each instance re-resolves identity for callers whose record it did not issue.


Once a request is authenticated, AuthProxy tells the backend who is calling. It does that with identity headers and both tenant headers, written on proxied requests and on /.cratis/me calls — and it strips any inbound copy first, so a backend can treat them as proof rather than as a claim.

What is stripped is wider than what is written. Every inbound header whose name begins x-ms-client-principal is removed whatever it ends in, so a header AuthProxy never writes but a backend might read — x-ms-client-principal-idp, say — cannot be used to smuggle a claim. The tenant is removed under both x-cratis-tenant-id and the legacy Tenant-ID, before and again after the proxy resolves its own, so the only tenant a backend can receive is the one AuthProxy resolved. A request with no resolved tenant reaches the backend with no tenant header at all.

HeaderCarriesEncoding
x-ms-client-principalThe full client principal as base64-encoded JSONAlways base64, so always US-ASCII
x-ms-client-principal-idThe provider-local subjectVerbatim, or RFC 8187 — no sibling announces which
x-ms-client-principal-nameThe display name (userDetails)Verbatim, or RFC 8187 when it cannot travel verbatim
x-ms-client-principal-name*The RFC 8187 form of the display namePresent exactly when the plain header carries an encoded value
x-cratis-tenant-idThe resolved tenantVerbatim, or RFC 8187 — no sibling announces which
Tenant-IDThe same resolved tenant; forwarding deprecated until a future major releaseSame as x-cratis-tenant-id

A display name is whatever the identity provider says it is, and providers say things like Søren Wærstad, Ольга Иванова, 田中太郎. An HTTP header field is octets, and .NET refuses to write a character above U+007F to one — it throws before a byte reaches the socket. So a name outside US-ASCII did not arrive garbled at the backend; the proxied request failed at the gateway and the identity-endpoint call failed silently, and the person could not use the application at all.

AuthProxy now guarantees that every value it writes is printable US-ASCII. A value that already is one — and that could not be mistaken for an encoded one, see below — is sent byte for byte unchanged, so if your users’ names are ASCII nothing about the wire format changes and no extra header appears. Anything else is sent as an RFC 8187 ext-value: percent-encoded UTF-8 behind a self-describing UTF-8'' prefix.

x-ms-client-principal-name: UTF-8''S%C3%B8ren%20W%C3%A6rstad
x-ms-client-principal-name*: UTF-8''S%C3%B8ren%20W%C3%A6rstad

The starred sibling is the same idiom Content-Disposition uses to pair filename with filename* (RFC 6266 §4.3). Its presence is the signal: when x-ms-client-principal-name* is there, the plain header carries an encoded value; when it is absent, the plain header is the name verbatim. The plain header is always populated either way, so a backend that gates on the three headers being present keeps working untouched.

Because the encoder emits only RFC 8187 attr-char octets, an encoded value can never contain CR, LF or NUL. That alone is not the whole guarantee, though, because a consumer that decodes can reconstruct whatever the encoding hid. A name a person chose to write as UTF-8''victim%0D%0AX-Admin:%20true is printable US-ASCII from end to end, so it would travel verbatim — and a backend that decided to decode because the value looked encoded would get a carriage return, a line feed and a header of the caller’s choosing back out of it.

So the rule is not “encode what ASCII cannot carry”, it is encode anything that could be mistaken for an encoded value. A value beginning with UTF-8'' is itself encoded, however ordinary the rest of it is, and the sibling header is emitted for it. Decoding it returns the literal name the person typed. The guarantee that follows is precise:

Decode exactly the values the sibling header announces, exactly once, and no identity value can put CR, LF or NUL into a header on your side.

No realistic display name begins with UTF-8'', so nothing about ordinary traffic changes.

x-ms-client-principal is the canonical value

Section titled “x-ms-client-principal is the canonical value”

The base64 principal is the source of truth for identity. Its userDetails property is the exact original name — every code point, no encoding, no normalization — because base64 was never subject to the ASCII limit in the first place.

Read userDetails when you need the name itself. Read x-ms-client-principal-name when you want a convenient header and can either accept the encoded form or decode it. Cratis Arc already takes the first route: its Microsoft Identity Platform handler builds the user from the base64 principal, so an Arc backend behind AuthProxy sees exact Unicode names with no changes on its side.

// Decoding the header form yourself, when you are not on Arc.
// Gate on the sibling header, never on the prefix: the sibling is what AuthProxy states, and the
// prefix is only what the value looks like. A name a person typed as "UTF-8''..." looks encoded
// and is not, and decoding it because it looked encoded is how a display name becomes a header.
var name = request.Headers["x-ms-client-principal-name"].ToString();
if (request.Headers.ContainsKey("x-ms-client-principal-name*"))
{
name = Uri.UnescapeDataString(name["UTF-8''".Length..]);
}

Decode once, and once only. The result is the person’s name, and a name is data — put it in a model, a log field or a response body, never back into a header.


For machine-to-machine calls, configure a JWT Bearer handler:

{
"Cratis": {
"AuthProxy": {
"Authentication": {
"JwtBearer": {
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"Audience": "<api-audience>"
}
}
}
}
}

The handler applies to every path, but a token from an issuer configured on a bearer route never reaches it: such a token is refused on every path outside its bearer routes.


Bearer routes: access tokens from an authorization server

Section titled “Bearer routes: access tokens from an authorization server”

A service can declare bearer routes: path prefixes such as /mcp or /v1 that are authenticated by an access token from an external authorization server — for example Cratis Identity — rather than by a browser session. AuthProxy remains an edge and relying party: it validates the token and forwards the request with the same trusted headers a browser session gets.

A token is accepted only when all of these hold:

  • It is a signed JWT (JWS); encrypted tokens and alg: none are refused. The algorithm is RS256 or ES256.
  • Its iss is exactly one of the route’s issuers.
  • Its signature verifies against a key in the issuer’s JWKS. The metadata document (RFC 8414, or OpenID Connect discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs no restart. An allowed refresh completes before validation is retried in the same request. Known-key lookups use cached keys without waiting for retrieval; due automatic refreshes and their retries run in the background. A failed refresh keeps the last trusted keys in use and backs off retrieval for 30 seconds, including before the first success. Metadata naming another issuer is never trusted. When no trusted keys have been retrieved, tokens are refused with 503, not as invalid.
  • Its typ header is an access-token type: the issuer’s TokenTypes, at+jwt or application/at+jwt by default.
  • Its aud names one of the route’s audiences.
  • It has an exp, and it is within its lifetime allowing the route’s clock skew (30 seconds by default).
  • It carries every scope the route requires, a single sub, and a single usable tenant.

Then the deployment’s claim requirements apply — the proxy-wide Authorization section and the route’s service’s own — to the principal after the route’s ClaimMappings, exactly as they apply to a browser session, together with the route’s own RequiredClaims. A route that sets IgnoreDeploymentRequiredClaims is held to its own requirements only, for a deployment whose requirements name a claim the issuer does not mint; AuthProxy logs a warning at startup for it. Role claims are dropped from the token first, so a requirement on a role can never be met by a bearer token. A mapping replaces every case variant of its target. Mapped sub, preferred_username and name must each have one usable source value; multiple values refuse the token. Mapping into the route’s tenant claim, or declaring targets differing only by case, is refused at startup.

SituationResponse
Path still percent-encoded after decoding, or containing a backslash, repeated / separators, a ; (path parameter, as in /mcp/..;/api) or a ./.. segment, on any route when bearer routes are configured400 before route selection, no challenge
No bearer token (a browser session does not count)401, WWW-Authenticate: Bearer resource_metadata="<ResourceMetadataUrl>"
Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single sub; or without a claim a ClaimMappings entry reads401, WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"
Token lacks a required scope403, WWW-Authenticate: Bearer error="insufficient_scope", scope="<required scopes>", resource_metadata="…"
Token does not satisfy the claim requirements that apply on the route403
Token carries no single usable tenant, or the tenant fails verification403
The issuer’s metadata or keys have never been retrieved, or the metadata names another issuer503 with Retry-After: 30
Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several Authorization headers401, WWW-Authenticate: Bearer error="invalid_token"

resource_metadata is omitted when the route declares no ResourceMetadataUrl. Every refusal in the table carries Cache-Control: no-store and an empty body; the reason is logged, not returned.

The path of ResourceMetadataUrl (for example /.well-known/oauth-protected-resource/mcp) is forwarded to the service backend for GET and HEAD without authentication and without any identity headers, Cookie or Authorization, so a client can discover the authorization server before it has a token. The backend serves the document. Any other method on that path is answered 405 with Allow: GET, HEAD and Cache-Control: no-store.

A request accepted on a bearer route is forwarded to the service backend with:

HeaderValue
x-ms-client-principalThe principal: identityProvider from the route’s IdentityProvider, userId from sub, userDetails from preferred_username, else name, else sub, roles anonymous and authenticated, and the token’s claims — after the route’s ClaimMappings have applied.
x-ms-client-principal-id, x-ms-client-principal-nameAs for a browser session, from the same principal.
Tenant-IDThe tenant from the token.
x-cratis-token-scopeThe granted scopes, space-separated.
x-cratis-token-client-idThe client the token was issued to, from azp or else client_id. This is the client’s asserted identity: a public client cannot prove which program is using it.

The principal also carries claims AuthProxy writes itself: urn:cratis:bearer:issuer, urn:cratis:bearer:subject (the token’s own sub, kept when a mapping rewrites sub), urn:cratis:bearer:client-id and one urn:cratis:bearer:scope per scope. A token cannot supply any urn:cratis:bearer: or urn:cratis:identity: claim, and role claims in the token are not forwarded: a token never grants a role.

Every inbound copy of these headers is removed on every route, bearer or not. The Cookie header is not forwarded on a bearer route, and neither is Authorization unless the route sets ForwardAuthorizationHeader. Every other request header is passed through as for any proxied request; AuthProxy adds no Service-ID.

A bearer route never calls /.cratis/me, whatever IdentityVerification says. That endpoint answers for browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a token. The consequences:

  • Under explicitly configured IdentityVerification: BestEffort, a service answering 403 on /.cratis/me refuses a browser session — but not a token on a bearer route. A user whose browser session a service refuses there can still reach the service with a token. The backend must enforce tenant membership and what the user may do with the scopes the client was granted. AuthProxy logs a warning at startup for every bearer route in a deployment where some service answers /.cratis/me, unless the route sets AcceptWithoutIdentityVerification to state that this is intended.
  • Under IdentityVerification: Required (the default), AuthProxy refuses to start with a bearer route unless the route sets AcceptWithoutIdentityVerification.
  • No identity details are resolved, so no .cratis-identity cookie is written.

See Bearer routes.


AuthProxy can also issue bearer tokens itself after a proxied service verifies the supplied client credentials over a private back channel.

  1. The client sends POST /.cratis/token
  2. The request body uses standard OAuth form fields:
    • grant_type=client_credentials
    • service=<service-key> (optional when only one service has client credentials configured)
    • client_id=<client-id>
    • client_secret=<client-secret>
  3. AuthProxy calls the configured downstream verification endpoint with a JSON payload:
{
"service": "portal",
"routePrefix": "/api",
"clientId": "orders-api",
"clientSecret": "<client-secret>"
}
  1. Any 2xx response mints a bearer token scoped to that service and route prefix
  2. Any 4xx response rejects the credentials
  3. Any 5xx response is treated as a downstream verification failure

Successful responses from /.cratis/token look like this:

{
"access_token": "<authproxy-issued-token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<authproxy-issued-refresh-token>"
}

The issued bearer token can then be used on the configured route prefix (for example /api/**). AuthProxy validates that the token is used against the same configured service and route before forwarding the request.

Resolving a tenant from the verification response

Section titled “Resolving a tenant from the verification response”

The 2xx response from the verification endpoint may optionally include a JSON body with a tenant property:

{
"tenant": "acme"
}

When present, AuthProxy embeds that value in the minted access token (and any refresh token issued alongside it) as a cratis/tenant claim. The claim travels with the token for its entire lifetime, so every subsequent request authenticated with that token carries it.

To have AuthProxy resolve the tenant and set the x-cratis-tenant-id header on proxied requests, add a Claim tenant resolution strategy pointing at that claim type:

{
"Cratis": {
"AuthProxy": {
"TenantResolutions": [
{ "Strategy": "Claim", "Options": { "ClaimType": "cratis/tenant" } }
]
}
}
}

Like every other Claim-resolved value, the tenant returned by the verification endpoint is matched against the SourceIdentifiers configured for each entry in Cratis:AuthProxy:Tenants — it is not used directly as the Cratis tenant ID unless a tenant also lists it as one of its own source identifiers. See Tenant registry for how that mapping works.

A client can exchange a refresh token for a new access token without resupplying its client credentials:

  1. The client sends POST /.cratis/token
  2. The request body uses:
    • grant_type=refresh_token
    • refresh_token=<refresh-token>
  3. AuthProxy validates the refresh token and, if it is still valid, mints a new access token and a new refresh token for the same service, client, and tenant — the response shape is identical to the one shown above.

Refresh tokens are valid for 30 days and are not re-verified against the downstream service on refresh — since the client secret is not resent, AuthProxy trusts the refresh token itself rather than calling back to the target service. There is no revocation list: a leaked refresh token remains usable until it naturally expires, so treat it as a credential and keep its exposure to the same standard as a client secret.

An expired or unrecognized refresh token is rejected with 401 Unauthorized and error: "invalid_grant". Refresh tokens cannot be used as access tokens (and vice versa) — each is protected separately, so presenting one where the other is expected is always rejected.

Data Protection keys and horizontal scaling

Section titled “Data Protection keys and horizontal scaling”

The authentication cookie and AuthProxy-issued client-credentials access and refresh tokens are all encrypted using ASP.NET Core Data Protection. By default, keys are not shared across instances. Running more than one AuthProxy replica, or needing sessions and client-credentials tokens to survive a restart, requires a shared key store. The simplest is a persistent, shared volume that Cratis:AuthProxy:DataProtectionKeysPath points at; Azure Blob Storage and Redis are also supported, see Data Protection keys:

{
"Cratis": {
"AuthProxy": {
"DataProtectionKeysPath": "/mnt/dataprotection-keys"
}
}
}

Without this, a client-credentials token minted by one replica will fail to validate on another, and all outstanding tokens and sessions are invalidated on every restart.