One Login For Every Stand - the cover: many local environments fanning into one auth stand, four roles, one door

One Login For Every Stand

One self-hosted Supabase auth stand for every local environment: one door, four roles, a token audience that had to become a string, and the three answers the gateway gives.

👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. Running notes live on my GitHub: github.com/brilliant-almazov.

The auth stand, its four roles, its webhooks and the gateway's answers have been checked by QA. How the monolith picks the stand up was established by reading its code; the switch between local and production auth is planned.

This is part 1 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.

Why one login

Before anything can be broken across zones, something has to log in. Every local environment needs users, tokens, roles and the webhooks that production's auth fires on every change. The easy answer is a copy of the auth stack inside every environment. That answer gives as many auth stacks as environments, each slightly different, and each one a place where a token is shaped a little unlike production.

The answer we took is the opposite: one shared, self-hosted auth stand, and every local environment logs in against it. One place shapes tokens, one place holds the keys, one place fires the webhooks. When something about a token is wrong, it is wrong once and fixed once.

The stand is built on the official Supabase self-hosting setup with docker compose (Supabase docs). Nothing in it is our own auth code. What is ours is the door, the keys, the roles and how environments are told apart.

One door

The stand has exactly one door, and it is Traefik. The API gateway of the Supabase stack - Kong, in the version we run - stays inside, with no published port.

A flow: several local environments on the left, each a ghost tile; one arrow each into a Traefik tile, the only door; behind it the auth stand with Kong inside and no published port, and rest, storage and realtime behind Kong

Kong cannot simply be dropped. Rest, storage and realtime are reached through it, and without Kong inside, the sb_ API keys are rejected. So the design keeps the stack whole and changes only what faces the outside: one router in front, everything else private.

The stand now sits behind the shared edge router under a *.localhost name, the same way every other local service will. QA checked it there: the JWKS endpoint answers 200, and a token is issued with 200 for each of the four roles.

Keys and four roles

Tokens are signed with ES256 and carry a kid. The key id is what lets a verifier pick the right public key from a JWKS without a shared secret, and what lets keys rotate without everyone being told at once.

Roles come from a token hook. It issues four of them, each with the user_role claim the services read:

Role user_role Checked
root root token and refresh
admin admin token and refresh
specialist specialist token and refresh
user user token and refresh

Four tiles, one per role - root, admin, specialist, user - each with its user_role claim and a tested pill; a key icon notes ES256 with kid

All four were checked by QA end to end: a token issued, used, and refreshed.

An audience that had to be a string

One fix in the stand is worth its own section, because it is the kind of thing that only shows up when two systems meet.

The JWT standard allows aud to be a string or an array of strings. A token from the stand arrived with aud as an array. The monolith expected a string. In normal mode it cast the array to the literal text Array and compared that; in debug mode it threw.

Two halves: on the left, aud as an array, the monolith casts it to the text Array, refused; on the right, aud forced to one string, the monolith compares it, accepted

The stand now forces the audience to a single string, and every check QA runs asserts it. The monolith also verifies tokens offline, against the exact issuer - another thing QA checked - so a token from the wrong issuer fails without a network call.

An animation: a token travels from the auth stand to the monolith; with aud as an array the monolith tile turns red with the text Array; then with aud as a string it turns green; a pill reads checked on every run

The lesson is not about Supabase. A local stand is where two parts of a system agree on a format for the first time outside production. Better there than in production.

Webhooks as in production

Production's auth fires database webhooks on its tables, and services depend on them. A local stand without them would be a stand that logs in but never tells anyone.

So the stand has database webhooks on all auth and public tables, as production does. Because one stand serves many environments, each webhook is routed to the environment it belongs to.

A flow: auth and public tables into a webhook tile, then a router tile, then two environment tiles, each receiving only its own events

What the gateway answers

The gateway is the first thing that reads a token. It now gives exactly three answers, each with a trace:

200 token valid trace_id=<32 hex> 401 token_missing trace_id=<32 hex> 401 token_invalid trace_id=<32 hex> 

A dark log card with three lines: 200 token valid, 401 token_missing, 401 token_invalid, each with a trace id placeholder; the 401 token_invalid line highlighted

How the gateway gets there I know by the code, not from a measurement. It takes the auth key only as an inline JWKS, with no network fetch; it picks the issuer by the exact iss; it maps the four roles with a hierarchy, root above admin above specialist above user. A token whose key is missing is a 401. A write needs one more thing: the user's identity chain in the cache.

The two 401s are different on purpose. "No token" and "a token that does not verify" are different defects with different owners, and the machine reason says which one it is without anyone opening a log.

The monolith side

This part I know from reading code, not from a run, and I say it that way.

The monolith supports more than one auth integration. A second integration, local, is picked up as a mounted configuration file - no code change. Its JWT secret is a JWKS rather than a shared secret, which is what makes ES256 with kid work. The webhook address for that integration is a route plus a hashid of the integration's id.

# a second integration as a mounted file - established by reading code integration: local jwt_secret: <JWKS of the auth stand> webhook: <route>/<hashid of the integration id> 

A dark card with the second integration as a mounted file: integration local, the JWT secret as the stand's JWKS, the webhook as a route plus a hashid; a pill reads established by reading code

What is next

Two things are not done:

  • a switch, ENV_AUTH=local|production, so an environment chooses where it logs in with one variable - planned;
  • production-shaped names <service>.<env>.localhost for the other services behind the one edge router, several environments at once - in progress. The auth stand already lives under such a name.

Three tiles: auth stand under a localhost name, tested; ENV_AUTH local or production, planned; service.env.localhost for the other services, in progress

What this buys a team

One stand instead of one per environment: fewer copies, one place where token shape is decided, and defects of the kind the array audience was found before production. The cost is a shared dependency - if the stand is down, every local environment is logged out together. For a laptop that is a fair trade.

With AI coding agents, the plumbing - a token hook, a router rule, a webhook route - is quick to write. Deciding that there is one stand, not many, is the part that is not typing.

What I do not claim

  • Not that the monolith side or the gateway's key handling was run. Both were established by reading code.
  • Not that the switch between local and production auth exists. It is planned.
  • Not that every service already has a .localhost name. Only the auth stand does; the rest are in progress.
  • Not that a shared stand fits every team. It is a shared dependency.

What of this is method

  • One stand, not many. A format decided once is a format that cannot drift.
  • One door. Keep the vendor's stack whole; change only what faces outside.
  • Two kinds of 401. A machine reason names the defect before anyone opens a log.

Cross-anything on a laptop - Part 1. Next: Four Failure Domains.

If you run a copy of auth inside every environment, how do you keep them alike? If you share one auth stand, what happens to everyone when it is down? If you have been bitten by an array aud, where did it show up first? Which part of your auth would you rather find broken on a laptop?