Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · August 21, 2025 · 9 min read · 22 Views
Banner: Google sign-in failing with Error 400 redirect_uri_mismatch behind a TLS proxy

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_id in 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 claim X-Forwarded-For: <any IP> and spoof its address in logs and rate limiters.
  • Register both http:// and https:// 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-For instead of 172.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:// to https:// links. Nobody had noticed because nginx redirected them.

Regression guard

  • A post-deploy smoke check runs the curl | grep command above for each environment and fails the pipeline if the redirect_uri is 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-Proto and X-Forwarded-For rather 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

Add a label

#nginx #fastapi #uvicorn #oauth2 #google-identity
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.