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.
OIDC providers
Section titled “OIDC providers”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.
Tenant-aware authentication state
Section titled “Tenant-aware authentication state”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 configuredParentHost)
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:
- Request arrives at
https://some-tenant.cratis.studio/ - AuthProxy resolves tenant
some-tenantviaSubHost - Challenge is sent with protected state containing tenant metadata
- Provider redirects back to
https://auth.cratis.studio/signin-github?...&state=... - AuthProxy restores tenant metadata from state
- 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.
OidcProviderConfig properties
Section titled “OidcProviderConfig properties”| Property | Type | Description |
|---|---|---|
Name | string | Display name shown on the login selection page. |
Type | string | Provider type hint (Microsoft, Google, or Custom). |
Authority | string | OIDC authority URL. |
ClientId | string | OAuth 2.0 client ID. |
ClientSecret | string | OAuth 2.0 client secret. |
Scopes | string[] | Additional scopes to request (beyond openid, profile, email). |
OAuth 2.0 providers
Section titled “OAuth 2.0 providers”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" } } ] } } }}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.
OAuthProviderConfig properties
Section titled “OAuthProviderConfig properties”| Property | Type | Description |
|---|---|---|
Name | string | Display name shown on the login selection page, and the source of the scheme name. |
Type | string | Provider brand (GitHub, Microsoft, Google, Apple, or Custom). Picks the logo, and for GitHub also enables organization and team claims. |
AuthorizationEndpoint | string | The OAuth 2.0 authorization endpoint URL. |
TokenEndpoint | string | The OAuth 2.0 token endpoint URL. |
UserInformationEndpoint | string | The user-information (profile) API endpoint URL. |
ClientId | string | OAuth 2.0 client ID. |
ClientSecret | string | OAuth 2.0 client secret. |
Scopes | string[] | Scopes to request. Adding read:org to a GitHub provider also adds organization and team claims to the session. |
ClaimMappings | object | Claim type → user-info JSON field name. |
Configuring a provider decides who can sign in, which on a public provider is everybody. To decide who may then get through, see Authorization.
Session lifetime and re-validation
Section titled “Session lifetime and re-validation”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, "IdentityRevalidationInterval": "00:10:00", "TenantRevalidationInterval": "00:10:00" } } }}| Property | Default | Description |
|---|---|---|
Lifetime | 12:00:00 | Absolute 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. |
SlidingExpiration | false | Whether activity extends the ticket lifetime. Disabled by default so Lifetime is a hard bound. |
IdentityRevalidationInterval | 00:10:00 | How long a resolved authorization is remembered before the identity details — and the authorization they represent — are re-resolved against the services. Zero or negative falls back to ten minutes. |
TenantRevalidationInterval | 00:10:00 | How 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.
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 two identity cookies
Section titled “The two identity cookies”The resolved identity is written to two cookies, and the split is a security boundary rather than an implementation detail:
| Cookie | Readable by script | Contents | Role |
|---|---|---|---|
.cratis-identity | Yes | Base64 JSON identity details | Lets a frontend render the signed-in user without a round-trip. Never treated by AuthProxy as evidence of anything. |
.cratis-identity-authorization | No (HttpOnly) | A sealed, unforgeable record | Carries 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.
Deployments running more than one AuthProxy instance should configure a shared
DataProtectionKeysPath 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.
JWT Bearer (API)
Section titled “JWT Bearer (API)”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>" } } } }}Back-channel client credentials
Section titled “Back-channel client credentials”AuthProxy can also issue bearer tokens itself after a proxied service verifies the supplied client credentials over a private back channel.
- The client sends
POST /.cratis/token - The request body uses standard OAuth form fields:
grant_type=client_credentialsservice=<service-key>(optional when only one service has client credentials configured)client_id=<client-id>client_secret=<client-secret>
- AuthProxy calls the configured downstream verification endpoint with a JSON payload:
{ "service": "portal", "routePrefix": "/api", "clientId": "orders-api", "clientSecret": "<client-secret>"}- Any
2xxresponse mints a bearer token scoped to that service and route prefix - Any
4xxresponse rejects the credentials - Any
5xxresponse 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 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.
Refreshing a token
Section titled “Refreshing a token”A client can exchange a refresh token for a new access token without resupplying its client credentials:
- The client sends
POST /.cratis/token - The request body uses:
grant_type=refresh_tokenrefresh_token=<refresh-token>
- 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 mounting a persistent, shared volume and pointing Cratis:AuthProxy:DataProtectionKeysPath
at it:
{ "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.