Authentication
August 5, 2026 · View on GitHub
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
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
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
| 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). |
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
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)
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
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
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
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
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.