Skip to content

Authorization

Authentication tells you who is knocking. On a public host that is not the same as deciding whether they may come in.

Put AuthProxy on the internet with GitHub sign-in and the sign-in works — for everybody. Every GitHub account on earth is a real, verified identity, so every one of them completes the handshake and lands on your application. The application is now responsible for turning away people it has never heard of, on every endpoint, forever, and the first mistake is a stranger inside.

Cratis:AuthProxy:Authorization makes the proxy the first gate: an authenticated caller who does not carry what you require never reaches a service at all.

It is off unless you turn it on. Declare nothing and AuthProxy behaves exactly as it always has.


A requirement names a claim, and optionally the values you accept:

{
"Cratis": {
"AuthProxy": {
"Authorization": {
"RequiredClaims": [
{ "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] }
]
}
}
}
}

An authenticated caller carrying urn:github:organization with the value Cratis gets through. Anyone else — signed in or not, and no matter which provider signed them in — is answered with the not-authorized page and goes no further.

Leave AnyOf out to require only that the claim is present:

{ "Claim": "urn:example:entitlement" }

That is the right shape when the identity provider already emits the claim only for the people who should get in, and enumerating the acceptable values in the proxy would mean restating — and then maintaining — a decision the provider has already made.

Values are compared case-insensitively, and surrounding whitespace is trimmed. Organization names, team slugs and role names are case-insensitive in the systems they come from, and Cratis versus cratis is not a distinction worth locking a deployment out over.


Two axes, and they compose in opposite directions. Say them out loud before you write them:

MeansReads as
Several entries in RequiredClaimsAll must be satisfiedand
Several values in one entry’s AnyOfAny one satisfies itor

So “in the Cratis organization and on the planner team” is two entries:

"RequiredClaims": [
{ "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] },
{ "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner" ] }
]

…while “on either of these two teams” is one entry with two values:

"RequiredClaims": [
{ "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner", "Cratis/operations" ] }
]

The distinction matters because the wrong reading fails in the dangerous direction. If entries were an or, adding a team requirement next to an organization requirement would widen access — to anyone in the organization or on that team in any organization — which is the opposite of what you meant by adding it.


The same section on a service applies to requests routed to that service, in addition to whatever the root requires:

{
"Cratis": {
"AuthProxy": {
"Authorization": {
"RequiredClaims": [
{ "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] }
]
},
"Services": {
"portal": {
"Frontend": { "BaseUrl": "http://portal-web:3000/" },
"Backend": { "BaseUrl": "http://portal:8080/" }
},
"admin": {
"Frontend": { "BaseUrl": "http://admin-web:3000/" },
"Authorization": {
"RequiredClaims": [
{ "Claim": "urn:github:team", "AnyOf": [ "Cratis/operations" ] }
]
}
}
}
}
}
}

Everyone reaching either service is in the Cratis organization; only the operations team reaches admin.

A service can therefore narrow who reaches it, never widen it. The alternative — a service section replacing the root’s — would turn the root into a default rather than a floor, so a section written to add an extra check would quietly drop the organization check, and a service added later with no section at all would be the way in.

Which service a request targets is worked out the same way the route table works it out: the single configured service when there is only one, otherwise the Service-ID header or the service query parameter. A request in a multi-service deployment that names neither reaches no service route either, so only the root requirements apply to it.


This is the case the feature was built for, and it needs one extra thing.

GitHub’s user endpoint — https://api.github.com/user, the one you configure as UserInformationEndpoint — returns a profile and nothing about membership. There is no organization field to map to a claim, so no amount of ClaimMappings produces one. Membership lives behind /user/orgs and /user/teams, and reading those requires the read:org scope.

Ask for read:org and AuthProxy fetches it for you, once, while the sign-in completes, and adds it to the session as claims:

ClaimValueExample
urn:github:organizationThe organization’s GitHub loginCratis
urn:github:teamorganization/team-slugCratis/planner

One claim per organization and one per team. The team slug is the name in the team’s URL — github.com/orgs/Cratis/teams/planner is the slug planner. It is qualified by its organization because a slug is only unique within one: two organizations may both have a developers team, and an unqualified claim would let membership of either satisfy a requirement written for one.

The scope is the opt-in. There is no second switch to forget, and no way for the two to disagree. Without read:org nothing extra is requested, no extra call is made, and sign-in is exactly what it was.

Here is a complete configuration restricting a host to the planner team in the Cratis organization:

{
"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", "read:org" ],
"ClaimMappings": {
"sub": "id",
"name": "name",
"preferred_username": "login",
"email": "email"
}
}
]
},
"Authorization": {
"RequiredClaims": [
{ "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] },
{ "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner" ] }
]
}
}
}
}

A few things worth knowing before you deploy it:

  • The claims are added at sign-in, not per request. They live in the authentication cookie, so membership revoked at GitHub takes effect when the session ends — bound by Cratis:AuthProxy:Session:Lifetime, twelve hours by default. Shorten it if you need revocation to bite sooner.
  • The application gets them too. They travel on the forwarded x-ms-client-principal header like every other claim, so your service can read organization and team membership without ever calling GitHub.
  • A failed read never breaks the sign-in. If GitHub is unreachable, or the token lacks the scope, the claims are simply absent — which the gate then refuses, with an explanation. That is the closed direction, reached gently.
  • Membership is read in pages of 100, up to five pages. Well past any plausible allow-list, and the bound keeps a sign-in someone is waiting on from turning into an unbounded crawl.
  • Type must be GitHub. It is what selects the logo on the provider-selection page, and what tells AuthProxy these endpoints are GitHub’s.

GitHub Enterprise works without further configuration: the membership endpoints are derived from whatever UserInformationEndpoint you configured, so https://github.example.com/api/v3/user resolves its own /user/orgs and /user/teams.


The not-authorized.html page, at 403, with a Sign out link.

Both halves are deliberate. A redirect back to the identity provider — the reflex for “not allowed” — is wrong here: the caller is signed in, so they would sign in again as the same person and loop forever. And 403 is a status a fetch() or a command-line client can act on, so browsers and non-browsers get the same honest answer.

The sign-out link matters because it is the only way forward. Someone who signed in with a personal account by mistake is otherwise stuck looking at a page that will not change. Override the page — brand it, tell people who to ask for access — by mounting a pages directory; see Well-Known Pages.

Every refusal is logged at warning level with the claim that was not satisfied and the path that was requested. The claim type only — never the value the caller carried, which would put an identity in the log to record that it was turned away.


Three things pass through untouched, and each for a reason worth knowing:

  • Callers with no session. They are refused — or sent to sign in — by the machinery that already does that. This gate is about who a signed-in caller is, not whether there is one.
  • The authentication endpoints themselves/.cratis/providers, /.cratis/login/{scheme}, the provider callbacks, /.cratis/logout and the /_pages assets. Gating the only route to acquiring the claims on already having them is a door locked from the inside.
  • Paths a service declares in AnonymousPaths. This is the one that surprises people, so be clear about it: a declared path exists precisely for callers with no session — a webhook receiver, a magic-link landing page — and a caller with no session carries no claims to satisfy anything with. Gating them would refuse every one of them, and a payment provider posting a webhook would get a 403 it can do nothing about. A declared anonymous path is public; requiring a claim somewhere else does not quietly un-declare it.

Everything above is settable purely through environment variables, which is how AuthProxy is normally deployed. Array entries are indexed:

Terminal window
Cratis__AuthProxy__Authorization__RequiredClaims__0__Claim=urn:github:organization
Cratis__AuthProxy__Authorization__RequiredClaims__0__AnyOf__0=Cratis
Cratis__AuthProxy__Authorization__RequiredClaims__1__Claim=urn:github:team
Cratis__AuthProxy__Authorization__RequiredClaims__1__AnyOf__0=Cratis/planner

Per service, the same list moves under the service key:

Terminal window
Cratis__AuthProxy__Services__admin__Authorization__RequiredClaims__0__Claim=urn:github:team
Cratis__AuthProxy__Services__admin__Authorization__RequiredClaims__0__AnyOf__0=Cratis/operations

Indices must start at 0 and not skip — a gap truncates the list where the gap is, silently dropping every requirement after it.

From a .NET Aspire app host, the same thing is expressed as builder calls:

builder.AddAuthProxy("authproxy")
.WithRequiredClaim("urn:github:organization", "Cratis")
.WithRequiredClaim("urn:github:team", "Cratis/planner")
.WithRequiredClaimForService("admin", "urn:github:team", "Cratis/operations");

Repeated calls append, so requirements can be declared wherever they belong in the app host rather than all in one place.


PropertyTypeDescription
Authorization.RequiredClaimsClaimRequirement[]Requirements every authenticated caller must satisfy. All of them must hold. Empty (the default) authorizes nobody away — the proxy authenticates without authorizing.
Authorization.RequiredClaims[].ClaimstringThe claim type the caller must carry. Blank fails startup.
Authorization.RequiredClaims[].AnyOfstring[]Values that satisfy the requirement, compared case-insensitively. Empty requires only that the claim is present.
Services.<key>.AuthorizationAuthorizationThe same shape, applied to requests routed to that service in addition to the root’s.

Sign in with an account that qualifies and one that does not. The second should land on the not-authorized page at 403, and nothing should reach your backend — no request, and no /.cratis/me identity call either, because the refusal happens before both.

If a qualifying account is also refused, the requirement and the claim disagree. Read the warning in the proxy’s log: it names the claim that was not satisfied. For GitHub, the usual causes are the read:org scope missing from Scopes, a team named by its display name rather than its URL slug, or a team written without its organization/ prefix.

If nobody is refused, no requirement bound at all. Check the indices in the environment variables — a skipped index truncates the list.

Next: Public Application Surfaces for the paths this gate deliberately leaves open, and Authentication for the providers it gates.