Skip to content

Unauthenticated Responses

AuthProxy answers a caller that cannot proceed in one of two ways: it serves a page, or it returns a status code. Which one it picks depends on the caller, not on the path.

Not on a closed deployment. Everything on this page describes a deployment in the default Public admission mode. Under CapabilityOnly none of it applies to a caller who has not been admitted: there is no page, no 401, no 403 and no redirect — every route and every method answers one byte-identical 404, and the rules below start applying only once a capability has been presented.


The provider-selection and tenant-selection pages are HTML, and HTML has to be delivered with a success status to render. That is the right answer to a person in a browser, and the wrong answer to everything else:

  • A webhook, an e-signing callback or any server-to-server integration reads HTTP 200 as delivered. It does not retry, nothing is queued for redelivery, and nothing errors — so a refused request looks exactly like a successful one.
  • A frontend’s fetch() passes the conventional response.ok check and only fails later, when parsing HTML as JSON. Arc’s own identity bootstrap calls /.cratis/me on boot and hits precisely this.

So AuthProxy serves a page only to a caller that is navigating to one, and refuses everyone else with a status code they can act on.


CallerResponse
A browser navigating to a documentThe page — provider selection at 200, or a redirect to the identity provider
Anything else401 Unauthorized, or 403 Forbidden when the caller is already authenticated

A caller is treated as navigating when either:

  1. It sends Sec-Fetch-Dest: document (or iframe / frame). Every current browser sends this header on every request, and it is the only signal that separates a navigation from a fetch() issued by the same browser on the same connection.
  2. It sends no Sec-Fetch-Dest at all — a client predating fetch metadata — and names text/html explicitly in Accept.

A wildcard Accept: */* does not count. That is what fetch(), curl and most webhook senders send, and reading it as “HTML will do” is what produced the 200 in the first place. A caller that states nothing at all is treated as an API caller, because it almost always is.


SituationBrowser navigationOther callers
Unauthenticated, multiple providers configured200 + select-provider.html401, no page, no .cratis-providers cookie
Unauthenticated, one provider configured302 to the provider401
Unauthenticated, no providers configuredForwardedForwarded
Authenticated, tenant selection required200 + select-tenant.html403, no page, no .cratis-tenants cookie
Lobby mode, no invitation401 + invitation-required.html401 + invitation-required.html

Two rows are worth reading twice.

No providers configured is forwarded either way. With nothing to authenticate against there is no refusal to convert, and refusing here would turn a proxy that challenges nobody into one that refuses everybody.

Tenant selection returns 403, not 401. The caller is authenticated; answering 401 would send a frontend back through a login it has already completed. This matches the 403 AuthProxy already returns when an authenticated user belongs to no organization.


This changes the status code an existing deployment returns to non-browser callers, and one case is worth checking before rolling out: an HTTP liveness or readiness probe pointed at AuthProxy.

A probe sends Accept: */* and no fetch metadata, so it is not a navigation. Pointed at / it used to get 200 (the selection page) or 302 (the provider redirect) — both of which a probe reads as healthy — and now gets 401, which it reads as unhealthy. The pod then fails to become ready.

That probe was never testing much: it asserted that the login chooser renders, not that anything behind the proxy works. Replace it with one of:

  • The management listener — an opt-in private port carrying AuthProxy’s own /health/live and /health/ready. This is the probe for the proxy: liveness answers while every dependency is down, and readiness verifies that the instance could actually serve an authenticated request.
  • A path the application serves and the deployment declares in AnonymousPaths — this actually exercises the proxy and the application, which is what a readiness probe is for.
  • A TCP socket probe, if all you need is “the container is listening”. Note that this proves only that a process accepted a connection — not that AuthProxy could serve anybody.

On a deployment in CapabilityOnly admission mode the middle option is not available either — a declared anonymous path answers 404 to an unadmitted probe. Use the management listener.

Note that the bare / cannot be declared anonymous — it would match every request and turn the whole service anonymous, so it is rejected. Name a real path.


  • !response.ok is now a correct check against AuthProxy for any non-navigating caller. It was not before.
  • A client that deliberately wants the selection page — a custom login shell rendering it in a frame, for instance — should send Sec-Fetch-Dest: document, or navigate to /.cratis/select-provider directly. That path is in the authentication UI skip list and is never intercepted.
  • Paths a service declares in AnonymousPaths never reach any of this. They are forwarded to the application, which answers them itself.