You're In The Right Place If…
- Clicking "Sign in with Google" (or connecting any Google API) shows "Access blocked: This app's request is invalid"
- The error details show "Error 400: redirect_uri_mismatch"
- It may work on localhost or a staging URL but fail in production — or the other way around
- It worked last week and broke after a deployment, domain change, or proxy/CDN change
What Google Is Actually Checking
When your app starts an OAuth flow, it sends users to accounts.google.com with a redirect_uri parameter — the address Google should send the user back to with the authorization code. Google looks up your OAuth client ID, pulls its list of Authorized redirect URIs, and compares the parameter against that list with strict string equality. Any difference — http vs https, www vs no-www, a port number, a missing trailing slash, a different subdomain — fails the check and shows users the "Access blocked" screen.
This is a security feature, not pedantry: if Google matched loosely, an attacker could register a lookalike path and capture your users' authorization codes. So Google will never "fix" this by being more lenient — your two URIs simply have to match.
First: Find Out What Your App Actually Sent
Do not guess. On the error screen, click "error details" (or add a developer email and check the details link) — Google shows you the exact redirect_uri it received. Alternatively, before clicking sign-in, copy the full accounts.google.com URL from the browser address bar (or your server logs) and read the redirect_uri query parameter out of it. That string is your ground truth.
Now open Google Cloud Console → APIs & Services → Credentials → your OAuth 2.0 Client ID, and compare it character-by-character against every entry under "Authorized redirect URIs". In our experience the mismatch is visible within seconds once you have both strings side by side.
The Five Classic Mismatches
- Scheme: your app sent http:// but you registered https:// (common on localhost and behind TLS-terminating proxies)
- Host: www.yourdomain.com vs yourdomain.com — these are different URIs to Google
- Port: http://localhost:3000/callback registered, but the app runs on :5173 (or vice versa)
- Path: /api/auth/callback vs /api/auth/callback/ — the trailing slash matters
- Environment drift: you registered the staging URL but are testing on production (or a preview deployment URL that changes on every deploy)
The Hidden Cause: Your Reverse Proxy Is Lying To Your App
Here is the variant that burns experienced developers. Many frameworks build the redirect URI dynamically from the incoming request: they read the Host (or X-Forwarded-Host) header and construct https://{host}/auth/callback. That works — until your app sits behind a reverse proxy, CDN worker, or load balancer that forwards requests to an internal hostname.
When that happens, your app sees the INTERNAL host (something like app-internal.your-platform.com) instead of your public domain, builds the redirect URI from it, and sends Google a URI you never registered. From the outside everything looks configured correctly, and the error makes no sense until you inspect the actual redirect_uri parameter being sent.
The robust fix: stop deriving the redirect URI from request headers. Pin it in configuration — one environment variable that holds the exact public callback URL — and use that value both when building the sign-in URL and when exchanging the authorization code for tokens (the token exchange must use the same redirect_uri too).
# .env — pin the public callback explicitly
GOOGLE_OAUTH_REDIRECT=https://yourdomain.com/api/google/oauth/callback
# app code — prefer the pinned value over anything from request headers
redirect_uri = os.environ.get("GOOGLE_OAUTH_REDIRECT") or derive_from_request(request) After You Fix It: Propagation And Caching
Changes to Authorized redirect URIs in Google Cloud Console can take a few minutes to a few hours to propagate (Google's own docs say "a few minutes to several hours" — in practice 5–15 minutes is typical). If you are sure the strings match and it still fails, wait ten minutes and try again in a private window before changing anything else.
The Fix, Step By Step
- 1
Capture the exact redirect_uri your app sends
Click "error details" on Google's block screen, or copy the accounts.google.com URL from the address bar and read its redirect_uri query parameter. This string is your ground truth — never work from what you think the app sends.
- 2
Compare against your registered URIs character-by-character
Google Cloud Console → APIs & Services → Credentials → your OAuth 2.0 Client ID → Authorized redirect URIs. Check scheme (http/https), host (www vs bare), port, path, and trailing slash. Any single-character difference is the bug.
- 3
Register the exact URI (or fix what the app sends)
Either add the URI your app actually sends to the authorized list, or — better — make the app send the URI you intend. Prefer one canonical public URL.
- 4
If you are behind a proxy or CDN: pin the redirect URI in config
Do not build the redirect URI from Host/X-Forwarded-Host headers. Set it in an environment variable (e.g. GOOGLE_OAUTH_REDIRECT=https://yourdomain.com/api/oauth/callback) and use that value in both the sign-in URL and the token exchange.
- 5
Wait for propagation, then retest in a private window
Console changes take ~5–15 minutes (occasionally longer). Test in an incognito window so cached OAuth state doesn't mislead you.
From the trenches
How we hit this on a real production site
We hit this exact error connecting Google Search Console to our own studio dashboard. The OAuth client was configured correctly, the callback URL was registered — and Google still blocked with redirect_uri_mismatch.
The cause was the hidden proxy variant: our public domain routes /api traffic through a Cloudflare Worker to the backend host. The backend built its redirect URI from the forwarded Host header, so instead of sending https://ourdomain.ca/api/google/oauth/callback it was sending the internal backend hostname — a URI Google had never seen.
The fix was four lines: read a pinned GOOGLE_OAUTH_REDIRECT environment variable first and only fall back to header-derived URIs when it is unset. Deployed, reconnected, working — total time from error to fix, about twenty minutes. If you would rather not spend your afternoon on this, that is literally what we do for clients.
Frequently Asked Questions
Why does it work on localhost but not in production?
You almost certainly registered your localhost callback (http://localhost:3000/...) but not the production one — or production sits behind a proxy that changes the hostname your app sees. Register the exact production URI and pin it in configuration rather than deriving it from request headers.
I fixed the URI in Google Cloud Console but still get the error. Why?
Two usual reasons: propagation delay (wait 5–15 minutes and retry in a private window), or the app sends a subtly different URI than you think — recheck the actual redirect_uri parameter in the accounts.google.com URL, not your assumption of it.
Does the redirect URI matter again after the user approves access?
Yes. When your server exchanges the authorization code for tokens, it must send the same redirect_uri used in the sign-in request. If the two differ, the token exchange fails with invalid_grant even though the consent screen worked.
Can I use a wildcard so any subdomain works?
No. Google does not allow wildcards, fragments, or path patterns in authorized redirect URIs. Register each exact URI you use — one per environment is the clean pattern.
Still stuck? Send us the exact error.
Paste the exact error message you're seeing and where it happens. We'll take a look — if it's quick we'll point you at the fix, and if it's deeper we'll tell you honestly what it takes.