Signed forwarded identity: an ingress attests the identity headers it sends with an HMAC-SHA256 over the request, so the headers are no longer trusted merely because they are present.
It is opt-in. Set trust_forwarded_identity_headers: true and forwarded_identity: [enabled: true, …]:
config :my_app, MyApp.Arc,
trust_forwarded_identity_headers: true,
forwarded_identity: [
enabled: true,
audience: "my-app",
keys: [[key_id: "k1", secret_base64: System.fetch_env!("ARC_FORWARDED_IDENTITY_KEY")]]
]Each secret is exactly 32 random bytes, standard-base64 encoded; configure 1 to 8 keys with distinct, case-sensitive ids, from your secret provider, never from a committed file. With signing enabled there is no unsigned fallback: a missing, malformed, expired, replayed or incorrectly signed credential establishes no identity, name, roles or actor.
The wire contract
The ingress sends exactly one x-cratis-forwarded-identity-signature value, v1.<kid>.<ts>.<nonce>.<mac>,
at most 256 ASCII bytes: kid matches [A-Za-z0-9_-]{1,64}, ts is decimal Unix seconds, nonce is
unpadded base64url of 32 fresh random bytes, and mac is unpadded base64url of the 32-byte
HMAC-SHA256 over the canonical bytes. The canonical bytes start with
Cratis.AuthProxy.ForwardedIdentity.v1\n followed by frames F(s) (the UTF-8 byte count, :, the
string, a line feed) for the audience, key id, timestamp, nonce, method, escaped path, the covered
header count, and each covered header's lowercase name and value in ordinal order. Covered headers
are every x-ms-client-principal* and x-cratis-on-behalf* header, x-cratis-token-client-id,
x-cratis-token-scope, x-cratis-tenant-id, tenant-id, x-cratis-microservice, service-id
and the configured tenant header. Every covered header must have exactly one value. The method and
the path (with percent-escape hex digits uppercased) are bound; the query and body are not.
A timestamp is accepted from 60 seconds in the past to 30 seconds in the future. The nonce is
reserved only after the MAC and the identity bindings succeed, so an invalid attempt cannot poison it.
Replay memory is per application process, like Arc on .NET: it is not shared across nodes or restarts.
The application must be started (MyApp.Arc in the supervision tree); without it replays cannot be
detected and every signed request is rejected.
This attests identity and bindings, not tenant existence, tenant membership, request-body integrity or authorization. Use TLS and restrict backend ingress; a shared-key verifier can also forge identities, so provision service-specific secrets.
Summary
Functions
Authenticates a connection. Returns {:authenticated, principal}, :anonymous (no credential),
or {:failed, reason} with a fixed reason code.
Whether any header carries a forwarded-identity credential.
Functions
@spec authenticate(Plug.Conn.t(), module(), keyword(), integer() | nil) :: {:authenticated, Arc.Principal.t()} | :anonymous | {:failed, String.t()}
Authenticates a connection. Returns {:authenticated, principal}, :anonymous (no credential),
or {:failed, reason} with a fixed reason code.
Whether any header carries a forwarded-identity credential.