Why staging users can never reach production: isolated environments for auth
Environments in Nulvless are separate stores, not a column. Here is what that rules out, and what moves between them instead.
On this page
Most products model environments as a column. Users, sessions and settings sit
in the same tables with an environment_id next to them, and every query
remembers to filter by it. That works until one query forgets.
In Nulvless, an environment is a full copy of the project, with its own users, sessions, identities, providers, templates and keys. This post is about why we chose that, what it costs, and what moves between environments instead.
A column is a promise every query has to keep
When environments share storage, isolation is a property of your code, not of your data. A missing filter in a support script, an admin endpoint that joins on the wrong key, or a cache keyed only by user id are enough to show a test account in production or send a real customer an email from staging.
These bugs are rare, and that is what makes them expensive. They do not show up in tests, because tests run in one environment at a time.
A separate store makes the bug impossible
Each environment answers on its own issuer URL:
organization
└── project · acme
├── environment · main → acme.nulvless.co
├── environment · staging → acme-staging.nulvless.co
└── environment · develop → acme-develop.nulvless.coA user created in develop does not exist in production, and never will,
because they were never written to that store. There is no filter to forget.
The issuer URL is also the first argument to createClient, so pointing an app
at another environment is a URL change and a key change. Keys are per
environment too: a publishable key from staging is not accepted by production.
What moves: configuration, as a diff
Data never moves between environments. Configuration does, but only when you promote it, and a promotion starts as a diff, not a write.
The Compare screen takes an origin and a destination and lists every setting that differs: sign-up rules, sign-in methods, password policy, MFA, tokens, social providers, sudo mode, attack protection, the OAuth server, rate limits and notifications. Each row has the destination's current value and the incoming one. Reading that list is the review.
Underneath, the diff is two sections, auth and communications, each a list
of field, before and after. The grouping you see in the console is only
presentation. The unit that moves is one settings field.
What a promotion deliberately leaves behind
Some things are never carried by a merge, and none of these are errors:
- Credentials and secrets. Provider API keys, SMTP passwords and OAuth client secrets are configured by hand in the destination. A staging key should never become a production key by accident.
- Users, sessions and identities. Promotion moves settings, never data.
- Edge functions. A function belongs to the project, not to an environment, so it has no place in an environment diff.
The first one is the one that surprises people. A provider can be promoted as enabled while its credentials are still missing in the destination, and then it fails at delivery time rather than at merge time. That is why the Compare screen warns about it, and why the recommended flow configures credentials once, before the first promotion that needs them.
Applying it
Merging is protected by sudo mode: confirming asks the person to prove they are still at the keyboard, and that confirmation lasts an hour, so a sequence of promotions asks once.
The merge then runs as a pipeline and produces a deployment, with each step timed: initialise, authentication, communications. A typical run finishes in about three seconds.
A merge cannot be undone, so the history matters. Every merge records who requested it, a timestamped event log and the exact before and after rows that were reviewed. Months later, that snapshot answers "who changed this, and what was it before" without anyone having written it down.
The cost, and why we accept it
Separate stores are not free. A new environment starts empty: no users, no providers, none of the configuration you built elsewhere. That is the point, and also the first surprise for most teams.
We think that trade is right for identity. A shared table saves a few clicks at setup and leaves a class of incident open forever. A separate store costs a deliberate promotion, and in exchange the worst environment bug becomes one that cannot happen.
The shape we recommend is simple: change things in develop, compare into
staging and read the diff, then promote into main last. If the diff gets
large, promote more often. The environments guide
covers each step in detail.
