Post

The OAuth Redirect URI Is Not a Suggestion

What I learned wiring Google OAuth in front of a self-hosted dashboard, and why redirect_uri_mismatch is usually a configuration comparison problem.

Audio summary: a short spoken version of this post.

I recently put Google sign-in in front of a self-hosted web dashboard.

The dashboard was already running behind HTTPS and a reverse proxy. The first version used a shared password at the proxy layer. That worked, but it made the dashboard feel like a private server rather than an application with a proper identity boundary.

The target architecture was straightforward:

1
2
3
4
Browser
  -> HTTPS reverse proxy
     -> OAuth2 proxy
        -> local dashboard

The dashboard itself did not need to learn about Google. The authentication layer could handle the OAuth flow, create a session cookie, and forward an authenticated request upstream.

The first login attempt failed immediately:

1
Error 400: redirect_uri_mismatch

This error is less mysterious than it looks.

The redirect URI is a contract

An OAuth login has a round trip:

  1. The application sends the browser to Google.
  2. Google authenticates the user.
  3. Google sends the browser back to the application.
  4. The application exchanges the authorization code for tokens.

The return address in step three is the redirect URI. It is not merely a hint. Google compares it with the list registered on the OAuth client. The comparison is effectively exact.

These are different redirect URIs:

1
2
3
4
https://dashboard.example.com/oauth2/callback
https://dashboard.example.com/auth/callback
https://dashboard.example.com/oauth2/callback/
http://dashboard.example.com/oauth2/callback

The differences are small to a person reading them. They are not small to the OAuth provider.

The scheme, hostname, path, port, and trailing slash all matter.

The mistake was in the boundary between two systems

The reverse proxy had one public hostname. The authentication proxy had another idea about its callback path. Google had a third value registered in the OAuth client.

Each individual component looked reasonable:

  • the public site loaded over HTTPS;
  • the OAuth client ID was correct;
  • the authentication proxy could reach Google;
  • the local dashboard was healthy;
  • the callback handler existed.

But OAuth does not care that the pieces are individually reasonable. It cares that the exact same URI appears in both places.

The useful debugging step was to inspect the authorization request generated by the running service rather than infer it from a configuration file. In the browser’s developer tools, I checked the redirect to Google’s authorization endpoint and decoded only its redirect_uri parameter. I did not copy or share the full authorization URL: it can contain transient state and identifying configuration.

The value to compare was the decoded callback URI:

1
https://dashboard.example.com/oauth2/callback

That was the value Google needed to find in the OAuth client’s Authorized redirect URIs list. Google’s web-server OAuth documentation requires the URI in the request to match a registered URI for that client.

The fix

I added the exact URI to the Google OAuth client:

1
https://dashboard.example.com/oauth2/callback

The following details mattered:

  • https, not http;
  • the public hostname, not 127.0.0.1;
  • the authentication proxy’s callback path;
  • no extra trailing slash;
  • no guessed port;
  • no callback path from an earlier design.

After saving the OAuth client, the login flow worked without changing the dashboard application itself.

Keeping the dashboard out of the public network

The authentication proxy and dashboard both remained on private upstream addresses behind the reverse proxy. Only the reverse proxy listened on the public HTTPS interface.

The resulting shape was:

1
2
3
4
Public HTTPS
  -> reverse proxy
     -> authentication proxy
        -> dashboard

That distinction is important. Google authentication is not a replacement for network boundaries, TLS, rate limiting, or careful proxy configuration.

I kept the existing network allowlist as an additional boundary. Google identity answers “who is this?” The network policy answers “from where may this service be reached?” Those are different questions, and it is useful to keep both.

Restricting the identity domain

A Google OAuth client can authenticate Google accounts, but authentication alone does not always mean authorization.

If a dashboard is intended only for an organisation, the proxy should enforce the allowed email domain or organisation policy after Google returns the identity. In a generic configuration that might look like:

1
email_domains = ["example.org"]

This is not a substitute for checking the actual claims and provider configuration. It is one layer in the access policy.

For a small private dashboard, I prefer an organisation-restricted Google Workspace application where possible, plus an explicit allowed-domain check in the authentication proxy.

Things not to put in a blog post

The working setup contains values that are useful to the machine but not useful to readers:

  • OAuth client secrets;
  • cookie-signing secrets;
  • client IDs when they identify a private deployment;
  • private hostnames and IP allowlists;
  • internal service names;
  • account email addresses;
  • screenshots containing authorization URLs or session cookies.

A technical explanation does not need those values. The important information is the relationship between the components and the verification method.

I also avoid copying an authorization URL into a ticket or chat. It contains state and can contain identifying information even when the client secret is not present.

A compact troubleshooting checklist

When Google reports redirect_uri_mismatch, I now check these in order:

  1. Inspect the actual authorization request generated by the application or proxy.
  2. Decode the redirect_uri query parameter.
  3. Compare it character-for-character with Google Cloud’s registered URI.
  4. Check HTTPS versus HTTP.
  5. Check the public hostname versus an upstream or localhost hostname.
  6. Check the path and trailing slash.
  7. Check whether a reverse proxy is rewriting the path.
  8. Check that the OAuth client ID in the running service is the one being edited.
  9. Save the Google Cloud change and retry in a fresh browser session.
  10. Never “fix” the problem by weakening TLS or exposing the upstream dashboard directly.

This process is faster than starting with the assumption that Google OAuth itself is broken.

The broader lesson

OAuth failures often happen at system boundaries. The identity provider knows one URL, the reverse proxy advertises another, and the application logs a third. Looking at only one configuration file creates false confidence.

The reliable source of truth is the request that actually leaves the running system.

For redirect errors, inspect the live authorization URL, extract the redirect URI, and compare it exactly with the provider’s registered value. Once I treated the URI as a strict contract instead of a descriptive label, the error became a two-minute configuration fix.

The dashboard was not failing to authenticate Google. Google was correctly refusing to send a credential to an address the application had not explicitly registered.

This post is licensed under CC BY 4.0 by the author.