You click Google or GitHub sign-in in production, finish the consent screen, and the browser bounces back to http://localhost:3000/auth/callback (blank page), to some unrelated domain, or the provider stops you cold with Error 400: redirect_uri_mismatch. This is the single most common OAuth configuration bug: the redirect_uri your app sends doesn’t match an entry in the provider’s allowed list.
Fastest fix: click sign-in, open DevTools Network, copy the redirect_uri= value the provider received (URL-decode it), and paste that exact string into the provider’s allowed-callback list. For Google that is Cloud Console → APIs & Services → Credentials → your OAuth client → Authorized redirect URIs. Save, wait a few minutes for Google to propagate, retry in an incognito window. That clears the mismatch in most cases; the rest of this page is for when it doesn’t, and for stopping it from coming back.
First: how strictly does YOUR provider match?
Not every provider matches the same way, and the original advice “everything is byte-exact” is only true for some. Match your provider to its real behavior before you start editing entries — this is the bucket you’re in.
| Provider | Matching behavior (as of June 2026) | Wildcards / subpaths |
|---|---|---|
| Google OAuth 2.0 | Byte-exact: scheme, host, port, path, trailing slash all must match | No wildcards; localhost may use any port, public URLs may not |
| GitHub OAuth App | Host + port exact; path must be a subdirectory of the registered callback; subdomains allowed | No *, but /callback/staging matches a /callback registration |
| Auth0 | Byte-exact per entry in Allowed Callback URLs | No wildcards in production tenants |
| Clerk | Byte-exact; if the URL isn’t registered, OAuth completes but no session is created | Native/custom-scheme URLs registered separately |
| Supabase Auth | Redirect URLs support glob wildcards (*, **, ?); Site URL is the default fallback | ** globstar allowed; the underlying OAuth client URI is still exact |
The practical takeaway: on Google, Auth0, and Clerk you are hunting for a one-character difference. On GitHub you have more slack (a single callback can cover sub-paths), and on Supabase you can use a wildcard for previews — but the OAuth client URI you register with Google/GitHub inside Supabase is still exact.
Common causes
Ordered by hit rate, highest first.
1. Production callback URL isn’t in the allowed list
Most common. You added http://localhost:3000/auth/callback during dev, deployed to prod, and never added https://yourdomain.com/auth/callback.
How to spot it: the provider error page shows redirect_uri_mismatch plus the URI it received. Compare that against the allowed list in the provider console.
2. Hardcoded localhost in code
const redirectUri = "http://localhost:3000/auth/callback"; // forever dev
That line never changes after deploy, so the prod request still sends localhost.
How to spot it: grep your codebase for a hardcoded localhost or a literal domain.
3. Scheme or trailing-slash mismatch
https://example.com/cb is not equal to https://example.com/cb/ (extra slash), and http:// is not equal to https://. On Google this fails outright.
How to spot it: DevTools → Network → inspect the redirect_uri= parameter and diff it against the allowed list character by character.
4. www vs apex domain
https://www.example.com/cb is not equal to https://example.com/cb. If your site serves both but the list only has one, requests from the other fail. (GitHub is the exception — it ignores sub-domains, so www and apex match the same callback.)
How to spot it: does your site do www to apex redirects, or the reverse?
5. Preview / staging URL not registered
Vercel previews use changing subdomains like myapp-git-feature-team.vercel.app. On byte-exact providers, OAuth will always fail in preview unless you handle it (see Step 4).
How to spot it: failing only on preview deployments, working on production.
6. Provider strips path or query
Some flows (older SAML / SSO) ignore the path and match only the origin. A ?from=signup query on the redirect URI gets dropped and you land on / instead of /auth/callback.
How to spot it: the redirect lands on / rather than your callback path.
Shortest path to fix
Step 1: Capture the exact redirect_uri the provider received
Click sign-in, open the Network tab, and find the request to the provider’s authorize endpoint:
https://accounts.google.com/o/oauth2/v2/auth?
client_id=...&
redirect_uri=https%3A%2F%2Fyourdomain.com%2Fauth%2Fcallback&
...
Copy the redirect_uri= value and URL-decode it (%3A becomes :, %2F becomes /). That decoded string is exactly what the provider sees and validates.
Step 2: Add it verbatim to the provider’s allowed list
Paste the decoded string, unchanged, into the right field. Current console paths as of June 2026:
Google Cloud Console:
APIs & Services -> Credentials -> (your OAuth 2.0 Client ID) -> Authorized redirect URIs
GitHub:
Settings -> Developer settings -> OAuth Apps -> (your app) -> Authorization callback URL
Auth0:
Applications -> (your app) -> Settings -> Allowed Callback URLs
Supabase:
Authentication -> URL Configuration -> Site URL + Redirect URLs
Clerk:
Configure -> (SSO connection / OAuth application) -> Redirect URLs
Save. Google changes can take a few minutes and occasionally up to a few hours to propagate; GitHub, Auth0, Supabase, and Clerk are effectively instant.
Step 3: Drive base URL from env, never hardcode
// Next.js example
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL; // never fall back to localhost
if (!baseUrl) throw new Error("NEXT_PUBLIC_SITE_URL not set");
const redirectUri = `${baseUrl}/auth/callback`;
Set it per-environment in your host platform (Vercel / Netlify / Railway):
Local (.env.local): NEXT_PUBLIC_SITE_URL=http://localhost:3000
Preview: NEXT_PUBLIC_SITE_URL=(dynamic — see Step 4)
Production: NEXT_PUBLIC_SITE_URL=https://yourdomain.com
A common subtle bug: failing loudly on a missing env var (the throw above) is better than silently defaulting to localhost, which produces exactly the blank-localhost-page symptom in production.
Step 4: Handle preview deployments
Each Vercel preview has a different subdomain, so a fixed entry won’t cover them. Pick the approach that matches your provider:
Supabase (recommended for previews):
Add a glob Redirect URL such as
https://*-<your-team-slug>.vercel.app/**
Supabase supports * ** and ? in Redirect URLs.
Google / Auth0 / Clerk (no wildcards):
Use process.env.VERCEL_URL to build the per-deploy origin, then
register ONE stable preview alias instead of every random subdomain:
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NEXT_PUBLIC_SITE_URL;
Register a fixed alias like
https://your-app-git-main-team.vercel.app/auth/callback
GitHub:
One callback URL covers sub-paths and sub-domains, so a single
https://your-app.vercel.app/auth/callback often suffices for staging.
Step 5: End-to-end verify in incognito
1. Open the production URL in a fresh incognito window
2. Click sign-in
3. Network tab: confirm the redirect_uri value is the prod URL
4. Complete the provider consent screen
5. Land back on your page, then check the session cookie is set
6. Refresh once and confirm the session persists
If any step fails, capture the console and network errors before changing anything else.
Step 6: Diff scheme / slash exactly
If it still fails, put the registered URI next to the one in the request and diff them literally:
echo "registered: https://example.com/auth/callback" # from the provider dashboard
echo "in request: https://example.com/auth/callback/" # from the Network tab (note slash)
On Google a one-character difference — a trailing slash, http vs https, a www — is enough to break it.
How to confirm it’s fixed
You are done when all three hold in a clean incognito session on the production domain:
- The authorize request’s
redirect_uriexactly matches a registered entry (noredirect_uri_mismatch). - After consent you land on your real callback path, not
/or a blank localhost page. - The session cookie is set and survives a hard refresh.
If only preview deploys still fail while production works, you’re in cause 5 — revisit Step 4.
Prevention
- Treat the allowed-redirect list as part of the deploy checklist: a new environment means a new redirect URI.
- Ban hardcoded
localhostand literal domains in code; add a CI lint rule that fails the build if it finds one. - Use one env var name across every environment (
NEXT_PUBLIC_SITE_URL) so behavior is consistent. - Plan previews up front: a Supabase wildcard, or a single stable alias on byte-exact providers.
- Always run a full sign-in flow on the production domain before calling a deploy done.
- Annotate allowed-list entries (env, owner, date added) so audits are quick.
- Periodically prune dead dev / staging URIs from the list — stale entries are an open redirect risk.
FAQ
Why does it work locally but fail in production?
Your code or env var still resolves to http://localhost:3000, or you simply never added the production callback to the allowed list. Capture the redirect_uri in the Network tab on the production domain and compare it to what’s registered.
I added the URL but still get redirect_uri_mismatch on Google.
Three usual reasons: Google hasn’t propagated yet (wait a few minutes, sometimes longer), you edited the wrong OAuth client ID, or there’s a character difference — most often a trailing slash or http vs https. Diff the two strings literally as in Step 6.
Does the trailing slash really matter?
On Google, Auth0, and Clerk, yes — …/callback and …/callback/ are different entries. GitHub is more forgiving because it matches sub-paths of the registered callback.
Can I use a wildcard for Vercel preview URLs?
Only on providers that support it. Supabase allows globs like https://*-team.vercel.app/** in Redirect URLs. Google, Auth0, and Clerk do not — register one stable preview alias instead.
OAuth succeeds but I’m logged out / have no session. The redirect landed somewhere your session code doesn’t run, or (on Clerk) the redirect URL wasn’t registered so the provider returned but no session was created. Confirm you land on the exact callback path and that the cookie is set in DevTools → Application → Cookies.
The provider drops my ?from=signup query parameter.
Some flows match only the origin and strip path/query. Don’t carry state in the redirect URI; use the OAuth state parameter (or store intent server-side keyed by state) and read it back in the callback handler.
Related
- CORS error
- Supabase env missing
- Local vs prod mismatch
- Firestore Composite Index Missing
- Object Storage Upload Denied
- S3 Presigned URL Returns 403 Mid-Upload on Large Files
- Database Migration Stuck on ALTER TABLE in Production
- MongoDB Aggregation With $lookup and $group Runs for 30 Seconds
Tags: #Backend #Debug #Troubleshooting