Sign-in Notifications
AuthProxy can notify the application every time a user actually signs in — that is, when a signed-out user completes an interactive identity-provider login and a fresh session is established. The application can then record the sign-in, for example to alert the user of a new sign-in from an unfamiliar location or device.
The notification is a service-to-service back-channel, mirroring the invite exchange
and the credential link callback: AuthProxy POSTs a small JSON payload to a configured
application endpoint.
When it fires
Section titled “When it fires”The notification fires only on a genuine logged-out → signed-in transition:
- ✅ A signed-out user is redirected to an identity provider, authenticates, and returns with a fresh ticket.
- ❌ An already-authenticated user making ordinary proxied requests — the existing session cookie is reused, no provider round-trip happens, and no notification is sent.
- ❌ The credential-linking flow — that authenticates a second provider without establishing a new primary session, so it is never reported as a sign-in.
Technically, AuthProxy hooks the provider callback (OnTicketReceived), which the framework raises only
when a new authentication ticket is delivered from a provider — not when an existing session is validated.
That is what scopes the event to real sign-ins rather than every request. A notification failure never breaks
the sign-in: the call is best-effort and any error is logged and swallowed.
The payload
Section titled “The payload”AuthProxy posts one of two JSON bodies to the configured NotifyUrl. A provider without
canonical federated identity keeps the legacy body:
{ "subject": "<provider subject>", "identityProvider": "<issuer / provider>", "ipAddress": "<resolved client IP>", "location": "<approximate location, may be empty>", "browser": "<parsed browser, e.g. Chrome>", "operatingSystem": "<parsed OS, e.g. Windows>", "userAgent": "<raw User-Agent header>"}An opted-in canonical provider adds providerKey and issuer; subject is the exact configured canonical
subject and identityProvider becomes the same compatibility value as providerKey:
{ "subject": "<configured canonical subject>", "providerKey": "<configured stable provider key>", "issuer": "<normalized validated or configured issuer>", "identityProvider": "<same value as providerKey>", "ipAddress": "<resolved client IP>", "location": "<approximate location, may be empty>", "browser": "<parsed browser, e.g. Chrome>", "operatingSystem": "<parsed OS, e.g. Windows>", "userAgent": "<raw User-Agent header>"}- Identity fields — legacy providers select
subjectandidentityProviderfrom the freshly authenticated principal. Canonical providers send the complete(providerKey, issuer, subject)tuple. Consumers must bind all three tuple components; neithersubjectalone noridentityProvideris a stable cross-provider account key. ipAddress— the client IP, taken from the left-mostX-Forwarded-Forentry (falling back to the connection’s remote address).location— a best-effort, coarse location. See Approximate location below.browser/operatingSystem— parsed from theUser-Agentheader with a lightweight built-in heuristic (no third-party user-agent database). Unrecognized values are sent as empty strings rather than guessed.userAgent— the raw header, so the application can do its own richer parsing if it wants to.
By default the notification carries no credential and relies on the endpoint being network-isolated (see Security). Configure the signed envelope to authenticate it instead.
Canonical identity is opt-in per provider, so an application migrating provider registrations must accept both body shapes. A notification says that a provider authenticated the tuple; it does not grant application membership, roles, scopes, or authorization.
Approximate location
Section titled “Approximate location”AuthProxy deliberately does not bundle a geo-IP database — that would be a heavy dependency and a data
pipeline of its own. The location is instead derived from what is already on the request:
- the resolved client IP (always sent); and
- coarse geo headers that a fronting CDN or reverse proxy may add — Cloudflare’s
CF-IPCountry, and the conventionalX-Geo-City/X-Geo-Region/X-Geo-CountryandX-AppEngine-City/-Region/-Countryheaders.
When those headers are present, location is assembled as City, Region, Country. When they are not
present, location is empty and only the IP travels — the application can resolve a fuller location from the
IP itself if it needs one. This keeps AuthProxy dependency-light while still recording a genuine approximate
location wherever the infrastructure provides one.
Note. The client IP and any derived location are personal data. Handle and retain them in the application accordingly.
Configuration
Section titled “Configuration”Set the application endpoint that records a completed sign-in under Cratis:AuthProxy:SignIn:NotifyUrl.
{ "Cratis": { "AuthProxy": { "SignIn": { "NotifyUrl": "https://studio.example.com/api/internal/sign-ins" } } }}Equivalent environment variable:
Cratis__AuthProxy__SignIn__NotifyUrl=https://studio.example.com/api/internal/sign-insWhen NotifyUrl is empty or the SignIn section is absent, sign-in notifications are disabled (nothing is
posted).
The signed envelope
Section titled “The signed envelope”Without further configuration the notification body is the only evidence the application has, so anything
that can reach the endpoint chooses which user gets recorded as signed in — including the subject,
providerKey and issuer. Set Cratis:AuthProxy:SignIn:Attestation and AuthProxy signs a short-lived RS256
JWS over each notification and sends it as Authorization: Bearer.
The body is unchanged. The envelope travels in a header, so an application already consuming notifications keeps parsing exactly the same JSON.
What the envelope binds
Section titled “What the envelope binds”The envelope is a profile of RFC 9449 (DPoP) rather than a scheme of its own. Six facts are bound:
| Fact | Carried by | Meaning |
|---|---|---|
| Provenance | iss + the kid JWS header | which AuthProxy deployment signed it, and under which key |
| Audience | aud | the single application entitled to consume it |
| Route | htm, htu | the method and target URI of the request it accompanies |
| Body | body_hash | base64url SHA-256 of the exact bytes posted |
| Time | iat, nbf, exp | the window it is valid in |
| Replay | jti | a random 256-bit identifier, unique per notification |
A purpose claim of sign-in-notification separates the envelope from every other assertion AuthProxy signs,
so an invitation attestation can never be presented in its place.
Two details a verifier must implement exactly:
htufollows RFC 9449 — the target URI without query and fragment. Compare it against the query-stripped request URI, not the raw target. Because the query is deliberately outside the binding, aNotifyUrlthat carries one would be signed without it — so AuthProxy refuses to start with a query onNotifyUrlonceAttestationis configured. Put anything the application needs in the body instead.body_hashis an AuthProxy extension — RFC 9449 defines no body digest. It uses the identical construction to that specification’sathclaim: unpadded base64url of the SHA-256 of the raw request body.
Verifying a notification
Section titled “Verifying a notification”- Reject the request outright if the
Authorization: Bearerheader is missing. - Select the public key by the JWS
kidheader from your pinned key set, and require RS256. - Validate
iss,aud,expandnbfwith no clock skew allowance beyond your own tolerance. - Require
purposeto besign-in-notification. - Compare
htmto the request method andhtuto the request URI with query and fragment removed. - Read the raw request body before deserializing it, and compare
body_hashto its SHA-256 digest. - Reject a
jtialready seen inside the envelope lifetime.
AuthProxy publishes no JWKS document. The verifying application pins the public keys by its own configuration and selects one by
kid— the same way the invitation authority consumes invitation attestations. Key rotation is therefore a coordinated configuration change on both sides.
Configuring it
Section titled “Configuring it”{ "Cratis": { "AuthProxy": { "SignIn": { "NotifyUrl": "https://studio.example.com/api/internal/sign-ins", "Attestation": { "Issuer": "https://auth.example.com", "Audience": "studio", "ActiveKeyId": "sign-in-2026-08", "Lifetime": "00:00:60", "SigningKeys": [ { "KeyId": "sign-in-2026-08", "PrivateKeyPem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" } ] } } } }}| Setting | Meaning |
|---|---|
Cratis:AuthProxy:SignIn:Attestation:Issuer | written to iss; required, and required to match at the verifier |
Cratis:AuthProxy:SignIn:Attestation:Audience | written to aud; names the one application entitled to the notification |
Cratis:AuthProxy:SignIn:Attestation:ActiveKeyId | the key new envelopes are signed with; must name exactly one configured key |
Cratis:AuthProxy:SignIn:Attestation:SigningKeys | the available keys, each a KeyId and a PEM-encoded RSA PrivateKeyPem of at least 2048 bits; every KeyId must be unique |
Cratis:AuthProxy:SignIn:Attestation:Lifetime | the envelope lifetime; between 10 and 60 seconds, defaulting to 60 |
Supply PrivateKeyPem through a secret provider. AuthProxy never returns or logs it — publish only the
matching public key to the application.
Key rotation. Add the new key to SigningKeys, publish its public half to the application, then move
ActiveKeyId to it. Keep the previous key configured until every envelope it signed has expired.
Compatibility and failure behavior
Section titled “Compatibility and failure behavior”- Leaving the section unset changes nothing. No
Authorizationheader is added and the body is byte-for-byte what it has always been. - Once configured, AuthProxy never downgrades. If an envelope cannot be signed — unusable key material,
an
ActiveKeyIdnaming no key — the notification is not posted at all and the failure is logged. A sign-in is never recorded on unauthenticated evidence. - Configuration is validated at startup, so an unusable key fails the process rather than silently
suppressing every sign-in notification. When attestation is configured,
NotifyUrlmust also be an absolute HTTPS URL (HTTP is accepted only for loopback development).
Security
Section titled “Security”Unsigned by default. With no Attestation section the notification JSON is not
signed and carries no credential. Point NotifyUrl at an internal application address that is
network-isolated from public traffic, or authenticate AuthProxy separately at the application endpoint.
Signed when configured. The envelope establishes that AuthProxy produced this exact notification, for this application, over this exact body, recently, and only once. Network isolation and an authenticated envelope are complementary — enabling one is not a reason to relax the other.
Either way, treat the identity tuple as authenticated provider metadata, then apply the application’s own authorization policy before changing any access or membership. A verified envelope proves the notification’s origin and integrity; it grants no membership, role, or scope.