redirect_uri_mismatch: the OAuth callback URL contract
September 14, 2026
More on Social sign-in with OmniAuth
- OmniAuth's CSRF protection: why the request phase is a POST
- Sign in with Apple in Rails: the provider with no client secret
- Testing OmniAuth in Rails, without touching the provider
- Running ten OAuth providers without ten code paths
- OAuth account linking: the three cases, and the one that bites
- Add Google sign-in to Rails 8, from scratch
Error 400: redirect_uri_mismatchis the most common failure in an OAuth integration and the least informative. Nothing is wrong with your routes. Nothing reached your application at all.The provider received a
redirect_uriparameter, compared it against the list registered in its console, found no exact match, and stopped. That is the whole mechanism, and it explains why the Rails log has nothing in it: the request that failed was made by the browser to Google, and the answer came back from Google.The social sign-in hub covers the handshake. This is the string the handshake opens with.
Where the string comes from
OmniAuth composes it in two methods:
callback_pathis the predictable part,/auth/google_oauth2/callback. The other two are not, and between them they decide the OmniAuth callback URL that reaches the provider.Read the
elsebranch, because it is the default and therefore the one you are running. The callback URL is derived from the request that started the flow. Scheme, host and port come from whatever arrived, which means the value sent to the provider is a property of the request rather than a property of your configuration.That is the root of every case below.
The four strings that look the same
Scheme. A proxy or load balancer that terminates TLS forwards plain HTTP to Puma, so
request.schemeis"http"and the derived URL startshttp://. The provider hashttps://registered. OmniAuth knows about this case, which is why the comment about nginx is sitting in its source and whyuri.scheme = 'https' if ssl?exists. That rewrite depends on the request being recognised as SSL, which depends on the forwarded headers arriving and being trusted.Host.
http://localhost:3000andhttp://127.0.0.1:3000are the same server and two different OAuth redirect URI values. Register the one you actually type into the address bar, and then type that one every time.Port.
:3000is part of the string. A colleague running on:3001gets a mismatch on a registration that works for everyone else.Trailing slash.
/auth/google_oauth2/callbackand/auth/google_oauth2/callback/are two entries. Providers do not normalise this for you.None of these is clever. All of them cost an hour the first time, because the error message names none of them.
The query string that comes along
callback_urlappendsquery_string. If the request that began the flow carried parameters, they are in theredirect_uriyou send.So a sign-in button on
/pricing?ref=twittercan produce a differentredirect_urifrom the same button on/pricing, and only one of them is registered. This is the version of the bug that survives a whole debugging session, because the URL is correct on the page you are testing and wrong on the page a real visitor arrived at.It is also an argument for the entry point being a
button_towith a clean path rather than a link from wherever the visitor happens to be standing.The line that stops the guessing
The first branch of
full_hostreturns a configured string untouched. No derivation, no dependence on the host header, no scheme rewrite to get right. The redirect URI becomes a constant you can read next to the one in the provider console and compare by eye.Per environment, because the value differs per environment. A
Procis the escape hatch for an application that genuinely answers on several hostnames and needs to pick one at request time, though that case is rarer than it feels and usually means the provider console needs another entry rather than the code needing a lambda.This project does not set
full_host, which is fine for a single-host deployment and is exactly the configuration that breaks the day a second hostname appears. That is a reasonable default to ship and a bad one to keep.Why your route file only shows half of it
The callback is a route. The request phase,
/auth/:provider, is not: it is mounted by the OmniAuth middleware, which is why declaring it yourself shadows the middleware and breaks the flow. A reader grepping the route file sees the return leg and not the departure, which is worth knowing before concluding that half the integration is missing.The failure route matters here too. When the provider rejects the request it can redirect back with an error rather than showing its own page, and without
/auth/failurethat lands on a routing error of your own that looks like a third, unrelated bug.What this page does not cover
The
stateparameter, which is the other half of callback security and fails with a different error. Token exchange, which happens after the redirect URI has already been accepted. And provider consoles, which move their settings around often enough that naming a menu path here would age badly.Adding Google sign-in from scratch walks the console setup with the current screens, and OmniAuth's CSRF protection covers the other error that looks like a routing problem and is not: a 404 on the request phase, which is the verb rather than the URL.