Google sign-in fails with Error 400: redirect_uri_mismatch only in production
Sign in with Google worked locally and on staging but failed in production with redirect_uri_mismatch. Behind the TLS proxy our FastAPI app built an http:// callback because Uvicorn did not trust the proxy's headers. How we traced and fixed it.
We added "Sign in with Google" to an internal-facing analytics dashboard used by about 300 partner accounts. It worked on laptops, it worked on staging, and the first person to click it in production saw Google's error page instead of the consent screen:
Access blocked: This app's request is invalid
You can't sign in because this app sent an invalid request. You can try again later, or contact
the developer about this issue.
Error 400: redirect_uri_mismatch
Clicking error details showed the request Google had received:
redirect_uri=http://insights.example.com/auth/google/callback
Note the scheme. The site is served only over HTTPS, and the redirect URI registered on the OAuth
client was https://insights.example.com/auth/google/callback.
Reproducing it
It reproduced on every production login, and nowhere else. We could see the wrong value without involving Google at all by inspecting the redirect our app issued:
curl -s -o /dev/null -w '%{redirect_url}\n' https://insights.example.com/login/google
# https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=...&redirect_uri=http%3A%2F%2Finsights.example.com%2Fauth%2Fgoogle%2Fcallback&scope=openid+email+profile&state=...
What we ruled out
- The URI not registered at all. The HTTPS callback was listed on the production client, and
Google's error details showed the
http://value we were sending, not a missing entry. - Wrong client ID per environment. The
client_idin the redirect matched the production client. Staging uses a different client with its own registered URIs. - Trailing slash or case. Google compares scheme, host, path, case and trailing slash exactly. The path and host matched character for character; only the scheme differed.
Root cause
The app builds its callback URL from the incoming request with request.url_for(...), so the
scheme comes from what the ASGI server believes the request used. In production, TLS terminates at
nginx, which forwards plain HTTP to Uvicorn in another container and sets X-Forwarded-Proto: https.
Uvicorn only honours that header from addresses listed in --forwarded-allow-ips, which defaults to
127.0.0.1. nginx was connecting from the Docker network (172.20.0.5), so Uvicorn ignored the
header and reported the scheme as http. On staging, nginx and the app ran on the same VM with
Uvicorn bound to localhost, so the default trusted address happened to match. That is why only
production broke.
What is happening under the hood
In the authorization code flow, the app sends the user to Google with a redirect_uri. Google
checks it against the URIs registered on that OAuth client and refuses to continue on any difference,
because the authorization code will be delivered to that URI. The same value must be sent again when
the code is exchanged for tokens.
Behind a reverse proxy, the app never sees the original request. It sees a new connection from the proxy:
| Hop | Scheme | Host | Who knows the truth |
|---|---|---|---|
| Browser to nginx | https |
insights.example.com |
nginx |
| nginx to Uvicorn | http |
Original host only if nginx forwards Host |
Only via X-Forwarded-* headers |
| What the app reports | Depends on whether Uvicorn trusts nginx |
Forwarded headers are client-controlled input unless they come from a proxy you trust, which is
why servers like Uvicorn ignore them by default from unknown peers. Uvicorn's --proxy-headers
setting is on by default, but it is restricted to the addresses in --forwarded-allow-ips (or the
FORWARDED_ALLOW_IPS environment variable).
Environment
| Component | Version / value |
|---|---|
| OS | Debian 12 containers on a single Docker host |
| Runtime | Python 3.12 |
| Framework | FastAPI 0.116.1 on Starlette 0.47.2 |
| ASGI server | Uvicorn 0.35.0, --proxy-headers default, no --forwarded-allow-ips |
| OAuth library | Authlib 1.6.1 (Starlette client) |
| Proxy | nginx 1.26 terminating TLS, upstream http://app:8000 |
| Google OAuth client | Web application type, one HTTPS redirect URI per environment |
Minimal reproduction
# app/main.py (excerpt)
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret, https_only=True)
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
client_kwargs={"scope": "openid email profile"},
)
@app.get("/login/google")
async def login_google(request: Request):
redirect_uri = str(request.url_for("google_callback")) # scheme taken from the request
return await oauth.google.authorize_redirect(request, redirect_uri)
@app.get("/auth/google/callback", name="google_callback")
async def google_callback(request: Request):
token = await oauth.google.authorize_access_token(request)
...
# Dockerfile CMD (before)
uvicorn app.main:app --host 0.0.0.0 --port 8000
Who is affected
Any web app that derives absolute URLs from the incoming request while running behind a load
balancer, ingress or reverse proxy: Django without SECURE_PROXY_SSL_HEADER, Express without
trust proxy, Laravel without trusted proxies, Spring without forwarded-header handling, and ASGI
servers whose trusted-proxy list does not include the proxy's address. The same mistake breaks
password reset links, payment return URLs and Stripe or PayPal redirect targets.
The fix
We fixed it in two layers: make the proxy chain report the truth, and stop depending on it for the one URL that must match exactly.
1. Trust the proxy, and only the proxy. We gave the proxy a fixed address on a dedicated Docker network and told Uvicorn to trust that network:
-uvicorn app.main:app --host 0.0.0.0 --port 8000
+uvicorn app.main:app --host 0.0.0.0 --port 8000 \
+ --proxy-headers --forwarded-allow-ips=172.20.0.0/24
The app container publishes no ports to the host, so the only peers on that network are nginx and our other internal services.
2. Make nginx send the headers explicitly. Our config forwarded Host but relied on defaults
for the rest:
location / {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
3. Build the OAuth callback from configuration. The redirect URI is registered per environment in Google Cloud, so we made it a per-environment setting too, instead of reconstructing it:
@app.get("/login/google")
async def login_google(request: Request):
return await oauth.google.authorize_redirect(request, settings.google_redirect_uri)
# production environment
GOOGLE_REDIRECT_URI=https://insights.example.com/auth/google/callback
Steps 1 and 2 fix every other absolute URL the app generates (emails, pagination links); step 3 means the login flow no longer depends on proxy topology at all.
Alternatives and trade-offs
--forwarded-allow-ips='*'. Easiest, and safe only if nothing but the proxy can reach the app port. If the port is ever exposed, any client can claimX-Forwarded-For: <any IP>and spoof its address in logs and rate limiters.- Register both
http://andhttps://redirect URIs. Google requires HTTPS for non-localhost redirect URIs, and even where a provider allows it, sending authorization codes over plain HTTP is exactly what redirect URI validation is meant to prevent. - Terminate TLS in Uvicorn. Removes the proxy hop but gives up nginx's static file serving and certificate automation we rely on.
How to test the fix
First, prove the app now sees HTTPS through the proxy:
curl -s -o /dev/null -w '%{redirect_url}\n' https://insights.example.com/login/google \
| grep -o 'redirect_uri=[^&]*'
# redirect_uri=https%3A%2F%2Finsights.example.com%2Fauth%2Fgoogle%2Fcallback
Then an automated check that does not need a browser: a pytest test that calls the login route
with the production settings and asserts on the Location header:
def test_google_login_uses_configured_https_callback(client, monkeypatch):
monkeypatch.setattr(settings, "google_redirect_uri",
"https://insights.example.com/auth/google/callback")
resp = client.get("/login/google", follow_redirects=False)
assert resp.status_code == 302
assert "redirect_uri=https%3A%2F%2Finsights.example.com%2Fauth%2Fgoogle%2Fcallback" in resp.headers["location"]
Confirming in production
- The first five partner users logged in successfully within ten minutes of the deploy; over the first week 271 accounts signed in with Google and the OAuth callback returned zero 4xx responses.
- Application logs now show real client IPs from
X-Forwarded-Forinstead of172.20.0.5, which also fixed our per-IP rate limiter. It had been treating all traffic as one client. - Password reset emails, which also build absolute links, switched from
http://tohttps://links. Nobody had noticed because nginx redirected them.
Regression guard
- A post-deploy smoke check runs the
curl | grepcommand above for each environment and fails the pipeline if theredirect_uriis not HTTPS on the expected host. - The Uvicorn command line, including
--forwarded-allow-ips, lives in the Dockerfile and is reviewed like code; the trusted range matches the Docker network defined in the compose file. - Our runbook lists every place a redirect URI is registered (Google, our payment provider, SSO partners) next to the environment variable that must match it.
Security implications
The original failure was safe: Google refused the mismatched URI. The fix is where risk appears.
Trusting forwarded headers from everyone lets clients forge their IP and scheme, which can bypass
IP allow lists, poison rate limiting, and in some frameworks change generated links in emails.
We trusted only the proxy subnet, kept the app port unpublished, and keep https_only=True on the
session cookie. The OAuth state parameter (handled by Authlib) is still validated on callback, and
the client secret stays in the secret store, not in the image.
Prevention checklist
- Register exactly one redirect URI per environment and configure the app with the same value.
- Configure trusted proxies explicitly for every app that runs behind TLS termination.
- Have the proxy set
X-Forwarded-ProtoandX-Forwarded-Forrather than relying on defaults. - Smoke-test generated absolute URLs after each deploy, per environment.
- Never widen trusted proxies to everyone if the app port can be reached directly.
References
- Google for Developers: Using OAuth 2.0 for Web Server Applications — https://developers.google.com/identity/protocols/oauth2/web-server
- Uvicorn documentation: Settings (
--proxy-headers,--forwarded-allow-ips) — https://uvicorn.dev/settings/ - Uvicorn documentation: Deployment — https://uvicorn.dev/deployment/
- FastAPI documentation: Behind a Proxy — https://fastapi.tiangolo.com/advanced/behind-a-proxy/
- nginx documentation: ngx_http_proxy_module — https://nginx.org/en/docs/http/ngx_http_proxy_module.html
Add a label
Embed this post
<iframe src="https://hibug.me/google-oauth-redirect-uri-mismatch-proxy" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.