LaunchKit

Add Google sign-in to Rails 8, from scratch

September 06, 2026

This is the whole path, from an empty Google Cloud console to a working "Sign in with Google" button in a plain Rails 8 application. No gems beyond OmniAuth itself, and no framework other than Rails.

Budget about ten minutes for the Google side and an afternoon for the Rails side, most of it spent on the decisions in the last section rather than on typing.

Part 1: get the keys out of Google

You need exactly two values: a Client ID, which looks like 1234567890-abc123.apps.googleusercontent.com, and a Client secret, a short opaque string.

Create a project

Open the Google Cloud Console, use the project picker in the top bar and click New project. A project is only a namespace for the credentials. You do not need billing enabled for OAuth sign-in.

Configure the consent screen

Google shows this to your users the first time they sign in, so it has to exist before you can create a client.

Go to APIs & Services → OAuth consent screen. Newer consoles group this under Google Auth Platform → Branding. Choose External as the user type, then fill the three required fields: app name, user support email, developer contact.

Leave Scopes empty here. You request email and profile at sign-in time from the application, which keeps the scope in your code rather than in a console someone else can change.

One thing that will catch you: while the app is in Testing, only accounts listed under Test users can sign in at all. Add your own Google account there before you try it, and click Publish app when you want anyone to be able to.

Create the OAuth client

Go to APIs & Services → Credentials, then Create credentials → OAuth client ID, and choose Web application as the type.

Two lists matter, and both are exact-match:

Authorized JavaScript origins takes the scheme, host and port only, with no path and no trailing slash:

http://localhost:3000
https://your-domain.com

Authorized redirect URIs takes the full callback path:

http://localhost:3000/auth/google_oauth2/callback
https://your-domain.com/auth/google_oauth2/callback

That path is not arbitrary. It is /auth/<strategy>/callback, and the strategy name for this gem is google_oauth2, not google. Getting it wrong is the single most common cause of the error in the troubleshooting section.

Add one origin and one redirect URI per environment you run. Click Create, and copy both values from the dialog.

Part 2: wire it into Rails

The gems

gem "omniauth"
gem "omniauth-google-oauth2"
gem "omniauth-rails_csrf_protection"

The third one is not optional, and the next section explains why.

Store the secrets

Client secrets do not belong in the repository or in an environment variable you will paste into a dashboard. Rails encrypts them per environment:

bin/rails credentials:edit --environment development
google:
  client_id: "1234567890-abc123.apps.googleusercontent.com"
  client_secret: "your-secret-here"

The encrypted file is safe to commit. The key that opens it is not, and Rails already gitignores it. Run the same command with --environment production and different Google credentials, because the redirect URIs differ and one leaked development secret should not open production.

The initializer

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  google = Rails.application.credentials.google

  if google&.client_id.present?
    provider :google_oauth2, google.client_id, google.client_secret, scope: "email,profile"
  end
end

OmniAuth.config.allowed_request_methods = [ :post ]

The guard matters more than it looks. Without it, a fresh clone with no credentials raises on boot, and every new developer's first five minutes is spent on an error that has nothing to do with what they were trying to do.

The routes

# config/routes.rb
get "/auth/:provider/callback", to: "sessions/omniauth#create"
get "/auth/failure",            to: "sessions/omniauth#failure"

You do not declare the request phase. The OmniAuth middleware answers /auth/google_oauth2 before your routes are consulted.

The button, and why it is not a link

<%= button_to "Sign in with Google", "/auth/google_oauth2",
      data: { turbo: false }, class: "..." %>

A plain <a href="/auth/google_oauth2"> works, which is precisely the problem. It is a login CSRF: an attacker can cause a victim's browser to start an OAuth flow and, under the right conditions, sign them into an account the attacker controls. Everything the victim then does happens in the attacker's account.

omniauth-rails_csrf_protection forces the request phase to be a POST carrying an authenticity token, which is what closes it. Plenty of tutorials still show the link version.

The identity table

create_table :oauth_identities do |t|
  t.references :user, null: false, foreign_key: true
  t.string :provider, null: false
  t.string :uid, null: false
  t.timestamps
  t.index [ :provider, :uid ], unique: true
end
class OauthIdentity < ApplicationRecord
  belongs_to :user

  validates :provider, presence: true
  validates :uid, presence: true, uniqueness: { scope: :provider }
end

The uniqueness is scoped to the provider rather than global, because two providers can hand you the same uid string and a global constraint would reject the second one for no reason a reader could understand. A user has many identities, which is what makes "connect my GitHub too" possible later.

The callback controller

# app/controllers/sessions/omniauth_controller.rb
module Sessions
  class OmniauthController < ApplicationController
    skip_before_action :verify_authenticity_token, only: :create

    def create
      user = find_or_create_user(request.env["omniauth.auth"])
      start_new_session_for(user)
      redirect_to root_path, notice: "Signed in."
    rescue ActiveRecord::RecordInvalid
      redirect_to new_session_path, alert: "We could not sign you in."
    end

    def failure
      redirect_to new_session_path, alert: "Sign-in was cancelled."
    end

    private

    def find_or_create_user(auth)
      identity = OauthIdentity.find_by(provider: auth.provider, uid: auth.uid)
      return identity.user if identity

      user = User.find_or_initialize_by(email_address: auth.info.email)
      if user.new_record?
        user.password = SecureRandom.base58(24)
        user.confirmed_at = Time.current
      end
      user.save!
      user.oauth_identities.create!(provider: auth.provider, uid: auth.uid)
      user
    end
  end
end

By the time create runs, OmniAuth has already validated the exchange with Google and populated request.env["omniauth.auth"]. The authenticity token is skipped on the callback because the request comes from Google, not from a form of yours; the request phase is where CSRF protection belongs, and the gem above handles it.

start_new_session_for is whatever your app already uses to sign someone in. On Rails 8's own authentication generator it is the method in the Authentication concern.

The three branches, and the one that matters

find_or_create_user above has three cases hiding in it, and only the middle one is a real decision.

The identity exists. A returning user. Note that the lookup is on Google's uid, never on the email. Email addresses change, and a user who updates their Google address and comes back would otherwise land in a brand new empty account with no error anywhere to tell you it happened.

No identity, but an account already has that email. You link them, and the reason you are allowed to is that Google verified the address. That verification is the entire safety property. A provider that does not verify emails cannot be trusted for this branch, and if you add one you have to special-case it rather than letting it fall through the same code.

Refusing to link instead is the other failure: the user ends up with two accounts, one holding their data and one they can actually get into.

Neither. Create the account. The random password is not decoration: has_secure_password validates presence on creation, and beyond that it keeps the forgot-password path open for someone who later loses access to their Google account. confirmed_at is set immediately because Google already proved the address, and asking the user to prove it again by email is asking twice.

What this covers, and what it does not

Everything above wires one provider. That is the right place to stop for a first pass, and the wrong place to stop if you expect a second, because nothing here has a seam for one.

Adding Facebook means a second provider line, a second credentials block, a second button, and a second entry in whatever renders the sign-in page. Adding Microsoft after that means a third of each. By the fourth you have the same five decisions copied five times, and the day you change how identities are linked you have to find all five.

The shape that survives is a registry: one list of entries carrying each provider's key, its OmniAuth strategy name, its label, the scope it asks for, and where its email lives in the payload. The initializer iterates it, the buttons iterate it, the settings screen iterates it. Adding a provider becomes one entry rather than one branch in five files.

That last detail is not cosmetic. The strategy names do not match the brand names: Google's is google_oauth2, Microsoft's is microsoft_graph, X is still twitter2 in most gems. And auth.info.email is not always where the email is, which is the sort of thing you discover in production on the one provider your test account did not use.

The error handling above is also the minimum. rescue ActiveRecord::RecordInvalid and a failure action cover the two obvious paths, and a real implementation has more: the user who clicks Cancel on the consent screen, the expired or replayed state parameter, the provider that is simply down, the identity that already belongs to another account and must be refused rather than moved, and the account whose email is already taken by someone who signed up with a password. Each of those wants a different message, and collapsing them into one "sign-in failed" is how you get support requests nobody can reproduce.

Troubleshooting

redirect_uri_mismatch means the URL Google received does not exactly match one of your authorized redirect URIs. Check the scheme, the host, the port and the exact path /auth/google_oauth2/callback, with no trailing slash. http and https are different entries.

access_blocked, or "app is being tested" means the app is still in Testing and the account is not a listed test user. Add it, or publish the app.

origin_mismatch means the browser's origin is not in the authorized JavaScript origins. Add the exact scheme, host and port, with no path.

The callback 404s usually means the strategy name is wrong somewhere. The gem's strategy is google_oauth2, so the path is /auth/google_oauth2/callback in Google's console, in your button and in your routes.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles