Guides
Build guide

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.

Dharmendra JagodanaUpdated Oct 18, 202610 min read

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-secret

Then 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
authentication
oauth
deployment

Frequently Asked Questions

Can I use the same auth client for localhost and production?

You can, and you should not. A client carries one secret, and sharing it means every developer who can read an .env file holds the production credential. Registering a localhost callback on the production client also widens what that client will accept. Share one client between localhost and previews instead.

Can I use a wildcard in a redirect URL?

Yes, with one narrow shape: a single asterisk as the entire leftmost host label, on a domain you own. https://*.preview.example.com/callback is accepted. The wildcard stands for exactly one label, and scheme, port and path still have to match exactly.

Why is https://*.vercel.app/callback rejected?

Because vercel.app is a public suffix, not a domain anyone owns. Accepting it would let any Vercel project in the world receive your authorization code. Point previews at a subdomain you control and wildcard that instead.

Do preview deployments need their own auth client?

No. Previews belong on the same non-production client as localhost. What they need is a redirect URL the changing hostname will actually match, which is a wildcard question rather than a client-count question.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime