One auth client per environment, and what goes on each
How to split auth clients and redirect URLs across dev, staging and production, when a wildcard is safe, and why localhost does not belong on a production client.
In short
Create one auth client per environment rather than one client carrying every environment redirect URL. Matching is exact on scheme, host, port and path, so localhost, a preview URL and production are three separate entries. BuildBase allows one wildcard, as the entire leftmost host label.
The redirect URL list is the first thing a BuildBase setup gets wrong, and it
gets it wrong in a way that looks fine for months. You create one auth client.
You paste in http://localhost:3000/callback, the preview URL and production,
because all three need to sign in. Every environment works. Nothing errors.
Then you think about what you just built. That production client will hand a
real user's authorization code to whatever happens to be listening on port 3000
of a laptop, if someone can get a browser to ask for it. And the client secret
sitting in your local .env is the same string that signs production's
identity tokens.
So the question the console does not ask you is: how many clients, and what goes on each one? One per environment.
What you are building
Three auth clients, one each for local development, staging and production. Each carries only the redirect URLs its own environment actually uses, and each app build reads its client ID from its own environment.
When it works, signing in locally and signing in on production are two
independent paths that share no credential. Deleting the development client
logs nobody out of production. Pasting production's client ID into your local
.env fails at the redirect URL, loudly, instead of quietly working.
Before you start
An organization with at least one login method enabled, since the client offers a subset of the methods that already exist, and an app already wired up through the SDK so you have somewhere to point a client. Admin access to the BuildBase console. The Get Started checklist puts "Enable login methods" immediately before "Create an auth client" for this reason, and that step's own description says your SDK config cannot be filled in until the client exists.
Why one client cannot cover two environments
Not a style preference. Four things sit on the client rather than beside it, and each one of them is single-valued.
The app server URLs. Creating a client requires applicationTokenUrl and
applicationProfileUrl. Both are one string, and that is the endpoint
BuildBase calls to mint a token for a user who just signed in. One client
points at one app server. Staging and production cannot both be it, so if you
share a client, one of the two environments is authenticating against the
other's backend.
The secret. Generated once at creation, shown once. And it is not only a password: the same value is the HMAC key that signs the identity JWT your app server verifies, and the revoke webhook. A development secret that leaks out of someone's dotfiles is a production signing key.
The login methods. The methods a client offers are a subset you tick per client, so staging can carry a password login you would not put on production, or the reverse.
The tracking scripts. Analytics and advertising scripts attach per client, and the reason is written into the type itself. One organization routinely runs a marketing site, a product and a staging build, and they do not share a measurement id.
There is a fifth reason, and it is the one that should decide it for you even
if the other four do not. Loopback redirect URLs ignore the port when they
match. A registered http://127.0.0.1/callback matches
http://127.0.0.1:51789/callback, and every other port, because native and CLI
clients bind a fresh ephemeral port on each run and RFC 8252 section 7.3 asks
for exactly that.
It is the right rule. It also means that registering one loopback entry does not open one port, it opens all of them on that host and path. That entry has no business being on the client your paying users authenticate against.
Where one shared client wins
If you have one environment, one developer and no staging, three clients is bookkeeping that buys you nothing. You will keep the loopback entry on the client anyway, because it is the only client, and the second and third client exist only to be forgotten about. Ship the one client, and split it the day you add a staging deploy or a second person.
The threshold is not team size, it is whether any environment has users who are not you. Once a real customer signs in through a client, the laptop entries have to come off it, and at that moment you need somewhere else for them to live.
Auth0, WorkOS and Clerk all draw this line a level higher than we do. Auth0 tells you to run a separate tenant per environment, WorkOS a separate environment with its own credentials and redirect URIs, and Clerk gives every application a development and a production instance out of the box, with a separate application as the documented workaround for staging.
So if you have used one of those, a client per environment will feel like the loose version of a habit you already have.
Step 1: create one client per environment
In the console, open auth settings and create a client. It asks for a title, a type, and the two application URLs that environment's app server answers on.
Then name it for a stranger. "Acme production", "Acme staging" and "Acme local" are worth the three seconds, because the next person reading this list is deciding which client ID goes into a deploy, and a list of four clients called "My app" is how the wrong one gets picked.
The type is a real decision, not a formality. A normal client is how your own
app signs its own users in, and that capability is granted on every plan. An
oauth2 client is something else: your app being the identity provider that
other applications authenticate against, which is a separate plan feature.
One route creates both, so it is easy to pick the second by accident and be refused, or to pick it and pay for a capability you are not using.
What you should see: the client is created with an empty redirect URL list. Nothing is prefilled, and the secret is displayed in full exactly once. Copy it now, into that environment's secret store and nowhere else. Every later read masks it down to the first five and last five characters, and the only way back is rotation, which invalidates the old one.
New oauth2 clients also require PKCE by default.
Step 2: add only the redirect URLs that environment uses
Matching is an exact string comparison against the registered list before anything clever happens. Which means these are four different entries, and three of them will not match the fourth:
https://app.example.com/callback
https://app.example.com/callback/
https://app.example.com/Callback
https://app.example.com:443/callbackThe trailing slash is the one that costs an afternoon. Register the string your app actually sends, character for character.
For the local client, loopback with the port you develop on:
http://localhost:3000/callbackEntries are deduplicated when you save, and each one is shape-checked by the same code in the browser and on the server, so the inline error you see while typing and the server's answer cannot disagree about shape.
One check the browser does not carry is the public suffix list, which is too large to be worth shipping to a browser. A wildcard pattern too broad to be anyone's domain, which the next section gets to, passes the inline check and is refused on save, so that one arrives in the toast rather than under the field.
One trap worth knowing. A plain, non-wildcard entry is accepted on very
permissive terms: anything the URL parser accepts, which still includes
private-use schemes like myapp://callback for native apps, and also fragments
and embedded credentials. That is deliberate, because tightening it would make
existing stored clients unsaveable the next time an admin edited them.
It does mean the console will happily save you a redirect URL with a username and password in it. Do not write one.
Step 3: point each build at its own client
The provider takes the org ID, the client ID and the redirect URL. Read all three from the environment rather than hardcoding them, so the same source tree produces a different wiring per deploy:
import { ApiVersion } from '@buildbase/sdk';
import { SaaSOSProvider } from '@buildbase/sdk/react';
export default function Providers({ children }) {
return (
<SaaSOSProvider
serverUrl={process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL}
version={ApiVersion.V1}
orgId={process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID}
auth={{
clientId: process.env.NEXT_PUBLIC_BUILDBASE_CLIENT_ID,
redirectUrl: process.env.NEXT_PUBLIC_BUILDBASE_REDIRECT_URL,
}}
>
{children}
</SaaSOSProvider>
);
}The redirectUrl you pass here is the value that arrives at the authorization
endpoint as redirect_uri, so it has to be one of the strings you registered in
step 2, exactly.
These are NEXT_PUBLIC_* values, which means they are baked into the bundle at
build time. Changing a client ID is a redeploy, not a restart. Our own
environment notes put it plainly: if a frontend variable is wrong in
production, the fix is in the dashboard and it still needs a redeploy, because
NEXT_PUBLIC_* is compiled into the bundle rather than read at runtime.
What you should see: sign in on each environment in turn and land back in that environment. If local redirects you to production, you are running production's client ID.
When your URLs genuinely vary
Exact matching is what the standards ask for. RFC 9700, the OAuth 2.0 security best current practice, requires an authorization server to compare a request's redirect URI against the registered ones as exact strings, with one carve-out: port numbers in loopback redirects for native apps. So the port relaxation in the previous section is not a liberty we took, it is in the specification. The draft OAuth 2.1 document asks for the same thing in its registration requirements.
We deviate once more, narrowly, because subdomain-per-tenant is the dominant multi-tenant SaaS shape and exact matching means one registered entry per customer.
A pattern may carry one asterisk, as the entire leftmost host label, consuming exactly one label:
https://*.app.example.com/callbackThat matches acme.app.example.com and beta.app.example.com. It does not
match a.b.app.example.com, because the asterisk is one label and not a
prefix. Scheme and path stay exact, and so does the port, with the same
loopback carve-out as above. Neither side may carry embedded credentials,
because the comparison runs on a parsed URL rather than the raw string. That is
what defeats the classic bypasses, where a hostile host is smuggled in front of
the real one with an @ or a #.
A pattern must also carry no query string and no fragment of its own. The fragment is refused when you save it. The query string is not, so a pattern with one saves cleanly and then silently matches nothing, which is worth knowing before you spend an evening on it.
Four more rules worth having in your head before you type one:
foo*.example.com,*.*.example.comanda.*.example.comare all refused. One asterisk, leftmost, whole label.- The literal remainder has to be a domain somebody can own. Patterns on a
public suffix are refused, which rules out
*.comand, more usefully for you,*.vercel.appand*.github.io. - Wildcards must be
https, unless the remainder is a loopback host, wherehttp://*.localhost:3000/callbackis allowed for local subdomain-per-tenant work. - Wildcards are console-only. Dynamic client registration, where an unattested agent registers itself, rejects them outright. There is no domain-ownership proof behind a pattern, so the admin typing it is the trust boundary.
Pick the narrowest pattern that covers your tenants, on a hostname you control.
Preview deployments, honestly
If your previews are served from a domain you own, wildcard the varying label
and you are done. https://*.preview.example.com/callback covers every branch
build.
If your previews are on the platform's shared domain, you cannot. A wildcard on
vercel.app is refused by the public suffix rule, and that refusal is correct:
the pattern would match every other customer's deployment on that domain too.
We are not unusual here. WorkOS documents the same rule and names *.vercel.app
as a rejected example, and Clerk's own documentation says it is currently not
possible to use their production API keys with your host's provided preview
domain.
The workable answer is to point preview builds at a stable staging client with one fixed redirect URL, and accept that a preview signs in as staging. It is less magical than a wildcard and it is the only version of this that is not a hole.
The failure modes
An unregistered redirect_uri is a dead end, not a redirect. You get HTTP
400 and a JSON body on the auth server, and the browser stops there:
{
"error": true,
"path": "redirect_uri",
"message": "invalid field redirect_uri , required of type :Invalid redirect URI"
}This looks broken and it is correct. Until the redirect URI is proven to belong
to the client, redirecting to it is the attack, so the error cannot be
delivered that way. Every parameter error after that check does come back to
your app as error and error_description on the redirect URL, with your
state echoed back. Which gives you a rule of thumb: a JSON error on the auth
server means the client ID or the redirect URL is wrong, and an error delivered
to your own callback means something else is. A sign-in that completes and then
bounces you around is neither, and usually
middleware.
The rejection is logged server-side with the registered list beside it, which is the first thing to ask for if you are self-hosting and the string looks identical to you.
redirect_uri_mismatch from Google or GitHub is a different problem. That
error comes from the provider, about the callback URI shown on the login
method, which is not the same value as the redirect URLs on your client.
Editing the client will not move it. The social login
guide covers that one.
A wildcard that saved but never matches. Registration rules are re-applied at match time, on purpose, so a pattern written before those rules existed cannot come alive later as something we would refuse to register today. If a pattern saves and never matches, re-save it and read the error.
One client per environment does not isolate everything. This is the part I would want someone to tell me. Magic links and the social sign-in start routes do not check against one client's list.
They check the candidate's origin against a set pooled from every redirect URL on every auth client in the organization, plus the organization's auth base URL. That pooling exists to stop someone asking the platform to email a victim a real sign-in link pointing at a domain they control.
The consequence: adding http://localhost:3000/callback to your development
client adds http://localhost:3000 to an allowlist that your production magic
link endpoint also consults. Per-client separation protects the authorization
code path completely and this origin path not at all.
If the two genuinely must not share an allowlist, they have to be separate organizations, which is a real cost most teams will reasonably decline. If you do not pay it, at least know that the loopback entry is organization-wide, and keep the list short.
What to carry away
One client per environment, because the app server URL, the secret, the login methods and the tracking scripts are all singular and all live on the client. Register the exact string your app sends, and keep loopback off anything real users touch. Reach for the wildcard only when the varying label is a tenant on a hostname you own - never to paper over an environment you were too lazy to give its own client.
npm i @buildbase/sdk