Auth clients and redirect URLs: you want two clients, not one per environment
How many auth clients to create, and how to split redirect URLs across localhost, preview deploys and production without registering a wildcard that will be refused.
In short
Create two auth clients, not three. Production gets its own; local development and preview deploys share the other, because they sit at the same trust level. Register exact redirect URLs on the production client, and cover shifting preview hostnames with a single leftmost-label wildcard on a subdomain you own.
Every OAuth vendor repeats the same rule: one client per environment. Follow it and you will create three, feel organized, and then discover that the one environment which actually hurt is no better off. Preview deploys get a new hostname on every push. No number of clients fixes a hostname you cannot predict.
That rule is one client too many, and it is aimed at the wrong problem. What splits environments is a trust boundary, not a count: production holds real users and a secret nobody should be able to read casually, and nothing else does. Localhost and preview deploys sit on the same side of that line. The preview problem is a different one, and it is a question of redirect URL matching.
What you are building
Two auth clients in the BuildBase console, each with its own client ID, secret
and list of redirect URLs. When it works, a developer clones the repo, runs it
on localhost:3000 and signs in without asking anyone for a credential; a
preview branch deploys and its sign-in button works on a hostname nobody
registered by hand; and production holds a secret that two people have, with a
callback list you could read aloud without wincing.
Before you start
An organization with login methods already enabled under User Management → Access → Authentication → Methods. Methods are an organization-wide catalogue and a client can only offer methods that already exist, so that step comes first. The sign-in methods to choose from are email and password, magic link, email and phone codes, passkeys, Google, LinkedIn, GitHub, Microsoft, Apple, an OpenID Connect provider of your own, and Ethereum and Solana wallets. Separately, every app is an OAuth 2.0 authorization server for agents and third-party apps, which is not a way for a person to sign in.
Step one: decide the split before you create anything
Create one client for production and one for everything else.
A second client buys you three things that matter here: its own secret, which you can rotate without touching production; its own subset of login methods, so a staging build can offer a password login you would rather not ship; and its own tracking scripts. If none of those matter to you for a given environment, that environment does not need a client of its own, it needs a redirect URL added to one that already exists.
Key takeaway
The question is never "how many environments do I have". It is "which environments need a different secret". For most teams the answer is two: production, and not-production.
What about staging? If staging runs against production data, it is production and belongs on the production client. If it runs against its own data and your developers can read its environment variables, it is not-production. The data decides it.
Step two: create the non-production client, and copy the secret
Go to User Management → Access → Authentication and create a client. Name it
for what it is rather than where it runs, so webapp-dev and not staging.
The create dialog shows the client secret once. Copy it then.
This is not a UI quirk, it is the design. Every later read of a client masks the secret down to its first five and last five characters, because the alternative was handing the organization's OAuth client secret in full to every role with read access on auth settings.
The masking came with a cost that the code records plainly: an admin who skipped past the create dialog had no route back to a working client except deleting it and making a new one, which lost its ID, its redirect URLs and its methods along with it. That is why rotation exists as its own action now. If you lose the secret, rotate it rather than rebuilding the client.
You should now see the client in the list with a clientId, a masked secret
preview, and an empty redirect URL list. Methods and redirect URLs are not part
of the create form; open the client you just made and pick its login methods
from the catalogue there.
Step three: register redirect URLs that will actually match
Add the exact URL your app sends, and then read that word again, because exact is doing more work here than it looks.
A registered entry goes through three passes. The first is raw string
equality against the URL your app sent. Not a parsed comparison, a string one.
If that fails and the entry contains an asterisk, the wildcard rules in step
four take over, and those do compare parsed URLs. If that fails too, one
relaxation is left: when both the registered entry and the requested URL are
http on a loopback host, the port is ignored, so a registered
http://127.0.0.1/callback matches http://127.0.0.1:51789/callback. That
carve-out exists for native and CLI clients, which bind a fresh port on every
run.
The first pass is the one that catches people. String equality means a trailing slash makes a different URL. It means that if your redirect URL carries a query string of its own, you register it with that query string attached, character for character. Build the URL in one place in your code and register that string, rather than assembling it per call site and hoping the two agree.
There is also less validation on a plain entry than you might assume. Anything
the URL parser accepts will save, which is deliberate: native apps register
private-use schemes like myapp://callback, and tightening it would make
clients that already exist unsaveable. So nothing stops you registering
http://staging.example.com/callback. Do not. Off your own machine the only
scheme that belongs in a redirect URL is https, and the wildcard rules below
are the one place that is actually enforced.
For local development, register http://localhost:3000/callback. For tenant
subdomains on your own machine, http://*.localhost:3000/callback works too,
which is how you test a subdomain-per-tenant app without editing /etc/hosts.
Loopback means 127.0.0.1, [::1], localhost and anything ending in
.localhost.
Step four: preview deploys, where everyone gets stuck
Point previews at a subdomain you own, then wildcard that subdomain.
A wildcard entry here is deliberately narrow. One asterisk, standing as the entire leftmost host label, consuming exactly one label:
https://*.preview.example.com/callback
build-417.preview.example.com matches. a.b.preview.example.com does not,
because the asterisk is one label and not a prefix. prefix-*.example.com is
refused outright, since the asterisk has to be the whole label rather than part
of one. Matching happens on a parsed URL rather than the raw string, which is
what defeats the classic bypasses where an attacker hides a hostname in the
userinfo or the fragment.
Parsing the URL also makes this pass more forgiving than the string one, in two
small ways. An empty path and / count as the same target, so on a wildcard
entry the trailing slash stops mattering. The query string and fragment on the
incoming URL are ignored too, since the authorization code and state get
appended after validation. Everything else stays exact: scheme, path, and the
port outside the localhost carve-out above. /callback/extra will not match
/callback.
And the entry everybody tries first:
https://*.vercel.app/callback
That one is refused on save, with an error telling you the host is too broad
because vercel.app is a public suffix. It is the correct answer even though
it is the annoying one. vercel.app is not a domain you own, it is a domain
anyone can deploy under, and a wildcard across it would let any project on that
platform receive your authorization code. The same check
refuses *.com, *.co.uk and *.github.io.
Which leaves you with real work to do rather than a config line: give previews
a stable hostname under a domain you control, through a CNAME or your host's
preview-domain feature, and send the app's redirect URL from an environment
variable rather than deriving it from window.location. Then one wildcard
entry covers every preview you will ever deploy.
Two more rules worth knowing before you rely on one. A wildcard must use
https, except on localhost. And a wildcard can only be added by an
authenticated admin: a client that registers itself through Dynamic Client
Registration is refused one outright, because a wildcard is only ever as safe
as whoever chose it.
Step five: create the production client and wire both up
Create the production client the same way, with one difference: every redirect URL on it is exact. Reach for a wildcard here only if you genuinely serve tenant subdomains, and then wildcard the tenant subdomain and nothing above it.
This is also where the method subset earns the second client. Your non-production client can offer an email and password login that keeps local testing quick, without that login ever showing up on the sign-in screen real users see. The subset is stored per client, not per organization, so enabling a method in the catalogue does not switch it on anywhere by itself. A client has to select it.
Keep the production secret somewhere only your server reads it. The client ID and the redirect URL are public by nature, and in a Next.js app both are build-time values:
NEXT_PUBLIC_BUILDBASE_CLIENT_ID=your-client-id
NEXT_PUBLIC_BUILDBASE_REDIRECT_URL=https://app.example.com/callback
BUILDBASE_CLIENT_SECRET=your-client-secretThen the provider reads whichever client the environment points it at:
import { ApiVersion } from '@buildbase/sdk';
import { SaaSOSProvider } from '@buildbase/sdk/react';
export default function Providers({ children }) {
return (
<SaaSOSProvider
serverUrl="https://api.yourapp.com"
version={ApiVersion.V1}
orgId="your-org-id"
auth={{
clientId: process.env.NEXT_PUBLIC_BUILDBASE_CLIENT_ID,
redirectUrl: process.env.NEXT_PUBLIC_BUILDBASE_REDIRECT_URL,
}}
locale="en"
>
{children}
</SaaSOSProvider>
);
}Both values are public and baked into the bundle, so a change to either needs a redeploy rather than a restart. The client secret is the one that stays server-side. Wiring the rest of the provider is unchanged; only these two values differ per environment.
What you should see: sign-in works on all three hostnames, and the client list shows two clients, not the three you were told to create.
The failure modes
The redirect URL does not match and login refuses. Compare the
redirect_uri on the authorize request against the registered entry, character
by character. Usually it is a missing port, a stray trailing slash, or http
arriving where a proxy was supposed to forward https. That slash surprises
people. They have read somewhere that it does not matter, and on an exact entry
it does, because that pass is a string comparison.
Sign-in succeeds, then the app bounces between the callback and the login page. That is not a redirect URL problem at all, even though it looks like one. It is middleware running before the session cookie is readable.
It works on app.example.com and fails on acme.app.example.com. If the
wildcard entry is right, look at the cookie
domain rather than the
redirect URL.
A login method vanishes from one client. Deleting a method organization-wide pulls it from every client that offered it, and each client's cache is invalidated at the same time so the sign-in screen updates immediately. The method is gone from the catalogue, so it cannot come back by editing the client.
The secret is gone. Rotate it. Do not delete the client, or you will lose the redirect URL list you just built.
Where Supabase wins, and where Clerk does
Supabase allows a full glob allowlist, and their own documentation gives the Vercel preview recipe directly, wildcarding across the platform domain. If preview deploys on a shared platform domain are the thing you care most about, that is genuinely less work than registering a subdomain you own. We refuse that shape on purpose, and the cost of the refusal lands on you at setup time.
Clerk separates development and production into distinct instances with different key prefixes, so a preview build cannot reach production users at all. We have no equivalent: there is no test mode here, and nothing labels a client as non-production except the name you gave it. The discipline is yours to keep.
Neither of those changes the advice. It just means the second client is doing a job that, elsewhere, the platform would do for you.
npm i @buildbase/sdk