Skip to content

Signed forwarded identity

Signed forwarding lets a provisioned ingress attest the identity headers it sends to Arc, using HMAC-SHA256. It is opt-in, server-side only, and disabled by default on both ASP.NET Core and HttpListener.

See also configuration, Microsoft identity, and on-behalf forwarding.

For the ES256 contract, see signed forwarded identity v2. The v1 contract below is unchanged.

Enable both Cratis:Arc:TrustForwardedIdentityHeaders and Cratis:Arc:ForwardedIdentity:Enabled. Set ForwardedIdentity:Audience to your integration’s audience and provision ForwardedIdentity:Keys with entries containing KeyId and SecretBase64. Each secret is exactly 32 freshly generated random bytes, standard-base64 encoded. Configure secrets through your secret provider, for example the configuration key Cratis__Arc__ForwardedIdentity__Keys__0__SecretBase64; never check them into configuration files. Do not reuse OIDC, cookie, or on-behalf keys.

You may configure 1–8 keys with distinct, case-sensitive IDs. Invalid enabled configuration fails forwarded authentication. With signing enabled there is no unsigned fallback: missing, malformed, expired, replayed, or incorrectly signed credentials do not establish forwarded identity, name, roles, or actor. Without signing enabled, existing forwarded-header behavior is unchanged, including the default distrust of forwarded identity headers.

Deploy a compatible signer before enabling this mode. AuthProxy 2.44.0 and later can sign the identity it forwards as an opt-in, per backend service: proxied requests, bearer-route requests, identity-details calls and tenant-list calls. Arc 22.168.0 and later verifies those envelopes. Signing is off by default, so configure it in AuthProxy for every backend that runs in signed-only mode. Any other producer that forwards identity to a signed-only Arc target must sign as well, or its requests do not establish identity.

Use TLS and restrict backend ingress. A shared-key verifier can also forge identities; provision service-specific secrets with least-privilege access. Synchronize clocks. This attests identity and bindings, not tenant existence, tenant membership, request-body integrity, or authorization.

A rejected forwarded credential is not an unconditional HTTP veto. Existing handler/scheme ordering and endpoint policy still apply. An earlier successful native authentication retains its own identity without forwarded augmentation. Anonymous endpoints remain accessible without promotion from invalid forwarding. A protected endpoint without successful authentication returns 401; a valid signed identity lacking a required role receives 403. Signature validity grants no additional roles.

On-behalf forwarding still requires the separate enabled RS256 assertion and actor checks. When both features are enabled, both envelopes must validate. Successful complete authentication is cached for an unchanged request only, including the attached actor. Reentry does not consume either replay token again. Mutating consumed input or relevant configuration invalidates reuse; a different request cannot reuse the success. Returned principals and actors are independent copies. Authorization is not cached.

Custom HTTP hosts must implement ICanReadForwardedIdentityRequest, exposing host-observed header values without further flattening and the raw origin-form request target. Both built-in hosts supply this capability. Arc takes one immutable snapshot: the MAC and all identity/actor construction use those same copied values, never a later read from the host or a flattened header dictionary.

Cardinality is host-observed, not a wire-level guarantee. ASP.NET Core exposes multiple observed values, including repeated names with different casing; Arc rejects them. Managed HttpListener exposes only the last observed value when a wire header is repeated. Earlier values are irrecoverable: the last observed value must be the signed value, otherwise no forwarded identity is established. A comma inside one observed value remains data, not a duplicate separator. Windows/http.sys wire behavior has not been verified. Ingresses should strip inbound identity headers and generate a single signed set; do not rely on this contract to detect repetitions already discarded by a host.

Signed v1 integrations can additionally require an authenticated immediate TLS peer:

{
"Cratis": {
"Arc": {
"ForwardedIdentity": {
"ClientCertificate": {
"Required": true,
"TrustedCertificateAuthoritiesPath": "/certificates/authproxy-client-ca.pem",
"AllowedDnsNames": ["authproxy.internal"],
"ReloadInterval": "00:01:00"
}
}
}
}
}

The host must explicitly configure every Kestrel HTTPS listener that receives forwarded identity to request client certificates, and AuthProxy must present its client certificate. Arc does not modify Kestrel HTTPS defaults, server certificate selection, protocols, or client-certificate validation callbacks. For example, include this in the host’s existing HTTPS configuration (do not register a second defaults callback: Kestrel replaces the previous one):

builder.WebHost.ConfigureKestrel(kestrel => kestrel.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.AllowCertificate;
// Retain the host's server certificate, protocol, and client-certificate validation policy here.
}));

Use AllowCertificate for listeners that also serve callers without a certificate, or retain stricter RequireCertificate settings. Configure the host’s TLS validation to accept the intended private CA. A host may explicitly call https.AllowAnyClientCertificate() to delegate certificate trust to Arc’s forwarded-identity admission checks, but that does not protect other endpoints or replace their authentication and authorization policies. Arc never installs this permissive callback itself or overrides a host validation callback.

When admission is required, Arc emits a startup warning about this listener prerequisite and validates the configured trust bundle. The warning is guidance, not confirmation that any listener requests certificates: effective per-listener TLS options are host-owned. If a listener does not request certificates, forwarded identity is refused even when a caller offers a valid certificate. DelayCertificate, HTTP/3, and HttpListener are unsupported for this admission mode. Arc reads the actual Kestrel TLS peer (ISslStreamFeature), not Request.IsHttps, X-Forwarded-Proto, certificate-forwarding headers, or a forwarded ITlsConnectionFeature. It checks chain and validity against only the configured PEM CA bundle, requires the clientAuth EKU, and matches a DNS SAN without wildcard expansion or common-name fallback. CN fallback, wildcard expansion, system-root fallback, and unrelated clients are not accepted. Handle revocation at the TLS listener or by operational CA/leaf rotation; Arc’s custom-root check disables network revocation and downloads.

The bundle is reread on the first admission check after each reload interval. Overlap old and new CAs in one PEM bundle, deploy the new client certificate, then remove the old CA. A missing, unreadable, empty, malformed, or oversized replacement retains the last good bundle and emits a warning containing the path and replacement hash, never its contents. Successful content changes advance the trust generation and clear cached chain results. Bundles are limited to 1 MiB and reload intervals must be at least 5 seconds. Required admission fails startup if the path, SAN allowlist, interval, or initial bundle is invalid. Admission warnings are limited to one per reason per minute and contain the reason, certificate SHA-256 fingerprint, subject, and suppressed count, never forwarded header values or certificate bytes.

Use TLS passthrough to Kestrel so it authenticates the immediate peer. Alternatively, use ClientCertificate.Required=false behind mesh mTLS with a signed envelope; that mode does not satisfy v2’s required transport admission. A TLS terminator cannot establish transport evidence by forwarding a certificate header. Restrict direct backend ingress; never turn an HTTP certificate-forwarding header into transport evidence. Native HttpListener hosts refuse startup with ClientCertificate.Required=true; use the ASP.NET Core/Kestrel adapter for mTLS.

This additional gate defaults to disabled for v1 compatibility. The internal v2 verifier always requires host-admitted immediate-hop evidence, even on replay-insensitive queries, before envelope parsing, bearer verification, or nonce reservation. V2 remains internal and is not a released configuration API. This implements the authenticated-TLS clause of the draft v2 contract.

Send exactly one x-cratis-forwarded-identity-signature field value:

v1.<kid>.<ts>.<nonce>.<mac>

The envelope is at most 256 ASCII bytes. kid matches [A-Za-z0-9_-]{1,64} and is case-sensitive. ts is unsigned invariant decimal Unix seconds with no leading zeros except zero. nonce is unpadded base64url of 32 cryptographically random bytes, freshly generated for every outbound attempt. mac is unpadded base64url of the 32-byte HMAC-SHA256 result. No algorithm negotiation, extra fields, padding, unknown versions, or multiple host-observed signature values are accepted.

Include every present header whose lowercase name starts with x-ms-client-principal or x-cratis-on-behalf, plus:

  • x-cratis-token-client-id
  • x-cratis-token-scope
  • x-cratis-tenant-id and tenant-id
  • x-cratis-microservice and service-id
  • The host’s configured Tenancy.HttpHeader, if different.

Exclude the signature itself, Authorization, cookies, and unrelated headers. Names are deduplicated case-insensitively, then sorted by lowercase ordinal name. Every covered name must have exactly one host-observed value. Multiple observed values are rejected; a comma in one legitimate value is not itself a duplicate. This cannot detect wire repetitions already collapsed to the last value by managed HttpListener. Unknown identity/on-behalf suffixes are covered too. Optional empty values are included; absent and empty differ. The principal, principal-id, and principal-name tuple must be present and nonempty.

Strip only outer HTTP OWS (SP/HTAB) from values. Preserve internal printable ASCII, commas, base64 padding, percent-escape spelling, and serialized whitespace. Reject remaining control characters and non-ASCII wire values. Unicode identities use existing base64/RFC 8187 encodings. After authenticating the bytes, the decoded subject must be nonempty and equal the RFC 8187-decoded principal-id. Invalid UTF-8, malformed principals, null claims/roles, and invalid extended IDs are rejected. The principal remains authoritative for provider claims.

Bind the exact uppercase HTTP method token and the final escaped path sent to the backend, including path base. Extract the path from the raw origin-form target before ?. Require a leading /, valid percent triplets, and no fragment, control, or non-ASCII characters. Normalize only percent-escape hex digits to uppercase. Never URL-decode, resolve dot segments, collapse slashes, or Unicode-normalize. /a%2fb and /a%2Fb match; /a/b and /a%252Fb do not match them.

The audience comes from configuration, never Host or X-Forwarded headers. Query and body are intentionally outside this identity-attestation contract. Changing ?x=1 to ?x=2 does not change the MAC; the complete-authentication cache may conservatively invalidate on that change.

Encode UTF-8 without BOM, using LF only. Start with literal Cratis.AuthProxy.ForwardedIdentity.v1\n. Append frames in this order:

F(audience) F(kid) F(ts) F(nonce) F(method) F(path) F(headerCount)
F(lowercaseHeaderName) F(value) [for each covered header in ordinal order]

F(s) is invariant decimal UTF-8 byte count, :, the exact string, and LF. Thus F("") is 0:\n, F("a:b") is 3:a:b\n, and F("é") is 2:é\n. There are no spaces between frames beyond their own content. Do not reserialize principal JSON. Reject more than 64 covered headers or more than 32 KiB of canonical bytes before decoding principal data.

Accept iff now - 60 <= ts <= now + 30, inclusive, in UTC seconds. No client-selected lifetime or skew exists. Reserve (audience, kid, nonce) only after MAC and identity bindings succeed. The process-wide replay cache holds at most 100,000 live entries and retains each through ts + 60 inclusive; full caches fail closed without evicting live entries. Invalid MACs do not consume nonces. A failed inner assertion never publishes a reusable complete success.

Replay detection is process-local: replicas and process restarts do not share history. A future-dated accepted envelope can remain valid for up to 90 seconds after first acceptance. This is not globally single-use delivery or a transaction signature. Fleet-wide replay enforcement needs a separately specified shared store.

For rotation, provision the new key to Arc first, switch the signer’s active key, then retain the old key through the final old envelope’s 60-second lifetime plus 30-second clock allowance. Remove it afterward. Unknown IDs never try a fallback key. Do not log secrets, envelopes, principal data, or request-cache snapshots; rejection reasons are fixed codes.

All material in this section is public test-only data, never deployable configuration. The 32-byte key is hexadecimal 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f; audience arc-test, ID k1, verifier time 1700000000. Principal JSON is exactly:

{"identityProvider":"aad","userId":"u","userDetails":"n","userRoles":[],"claims":[]}

Its standard padded-base64 representation (no padding is needed for these particular bytes) is:

eyJpZGVudGl0eVByb3ZpZGVyIjoiYWFkIiwidXNlcklkIjoidSIsInVzZXJEZXRhaWxzIjoibiIsInVzZXJSb2xlcyI6W10sImNsYWltcyI6W119

For POST /base/a%2fb?x=1, the exact canonical input follows. Every displayed line, including the last, ends in LF:

Cratis.AuthProxy.ForwardedIdentity.v1
8:arc-test
2:k1
10:1700000000
43:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
4:POST
11:/base/a%2Fb
1:3
21:x-ms-client-principal
112:eyJpZGVudGl0eVByb3ZpZGVyIjoiYWFkIiwidXNlcklkIjoidSIsInVzZXJEZXRhaWxzIjoibiIsInVzZXJSb2xlcyI6W10sImNsYWltcyI6W119
24:x-ms-client-principal-id
1:u
26:x-ms-client-principal-name
1:n

Expected MAC: jgVyScoemstnZgj25AE70omcBbrUuzl9bIoesbV_BHo. Complete request (HTTP line endings are CRLF; canonical line endings above remain LF):

POST /base/a%2fb?x=1 HTTP/1.1
Host: backend.example
x-ms-client-principal: eyJpZGVudGl0eVByb3ZpZGVyIjoiYWFkIiwidXNlcklkIjoidSIsInVzZXJEZXRhaWxzIjoibiIsInVzZXJSb2xlcyI6W10sImNsYWltcyI6W119
x-ms-client-principal-id: u
x-ms-client-principal-name: n
x-cratis-forwarded-identity-signature: v1.k1.1700000000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.jgVyScoemstnZgj25AE70omcBbrUuzl9bIoesbV_BHo
Content-Length: 0

Use isolated replay state when comparing equivalent representations. Nonce B is 32 01 bytes: AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE. Replacing only the nonce in the base request gives MAC ql7vBinbDwBTqcRq5op-eA4hyMWHf0LFh5MqPlG6qsI and envelope v1.k1.1700000000.AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE.ql7vBinbDwBTqcRq5op-eA4hyMWHf0LFh5MqPlG6qsI. These are independent requests and both can authenticate once.

Variation Expected forwarded authentication
Base vector Verified: subject u, name n, provider aad
Header order/case changed; path escape 2f becomes 2F Same canonical bytes and MAC
Principal, ID, name, token, tenant, service, actor, or assertion changed after signing Rejected
Method, audience, key ID, or escaped path changed Rejected
Query x=1 becomes x=2 MAC unchanged; no query-integrity claim
Multiple host-observed values (including case variants), extra envelope component, padding, or unknown version Rejected
Managed HttpListener repeats a wire field Only the last observed value is checked; it must be signed
Timestamp 1699999940 or 1700000030, freshly signed Accepted freshness edge
Timestamp 1699999939 or 1700000031, freshly signed Rejected freshness edge
Same nonce on a distinct request in this process Rejected after one acceptance
Invalid MAC followed by correctly signed same nonce Invalid attempt does not poison nonce
Valid outer envelope with invalid/missing inner assertion or actor Rejected when on-behalf material is present
Signing disabled, signature header present Existing unsigned trust behavior unchanged