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.
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 |
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.
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.
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.
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> 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> 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>.localhostfor the other services behind the one edge router, several environments at once -in progress. The auth stand already lives under such a name.
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
.localhostname. 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?








