LaunchKit

redirect_uri_mismatch: the OAuth callback URL contract

September 14, 2026

Error 400: redirect_uri_mismatch is 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_uri parameter, 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:

def callback_url
  full_host + callback_path + query_string
end

callback_path is 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.

def full_host
  case OmniAuth.config.full_host
  when String
    OmniAuth.config.full_host
  when Proc
    OmniAuth.config.full_host.call(env)
  else
    if request.scheme && URI.parse(request.url).absolute?
      uri = URI.parse(request.url.gsub(/\?.*$/, ''))
      uri.path = ''
      # sometimes the url is actually showing http inside rails because the
      # other layers (like nginx) have handled the ssl termination.
      uri.scheme = 'https' if ssl?
      uri.to_s
    else ''
    end
  end
end

Read the else branch, 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.scheme is "http" and the derived URL starts http://. The provider has https:// registered. OmniAuth knows about this case, which is why the comment about nginx is sitting in its source and why uri.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:3000 and http://127.0.0.1:3000 are 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. :3000 is part of the string. A colleague running on :3001 gets a mismatch on a registration that works for everyone else.

Trailing slash. /auth/google_oauth2/callback and /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_url appends query_string. If the request that began the flow carried parameters, they are in the redirect_uri you send.

So a sign-in button on /pricing?ref=twitter can produce a different redirect_uri from 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_to with a clean path rather than a link from wherever the visitor happens to be standing.

The line that stops the guessing

OmniAuth.config.full_host = "https://launchkit.codes"

The first branch of full_host returns 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 Proc is 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

get "/auth/:provider/callback", to: "sessions/omniauth#create"
get "/auth/failure",            to: "sessions/omniauth#failure"

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/failure that lands on a routing error of your own that looks like a third, unrelated bug.

What this page does not cover

The state parameter, 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.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles