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:
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.
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 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.
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 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.rbmoduleSessionsclassOmniauthController<ApplicationControllerskip_before_action:verify_authenticity_token,only: :createdefcreateuser=find_or_create_user(request.env["omniauth.auth"])start_new_session_for(user)redirect_toroot_path,notice: "Signed in."rescueActiveRecord::RecordInvalidredirect_tonew_session_path,alert: "We could not sign you in."enddeffailureredirect_tonew_session_path,alert: "Sign-in was cancelled."endprivatedeffind_or_create_user(auth)identity=OauthIdentity.find_by(provider: auth.provider,uid: auth.uid)returnidentity.userifidentityuser=User.find_or_initialize_by(email_address: auth.info.email)ifuser.new_record?user.password=SecureRandom.base58(24)user.confirmed_at=Time.currentenduser.save!user.oauth_identities.create!(provider: auth.provider,uid: auth.uid)userendendend
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.
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
emailandprofileat 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:
Authorized redirect URIs takes the full callback path:
That path is not arbitrary. It is
/auth/<strategy>/callback, and the strategy name for this gem isgoogle_oauth2, notgoogle. 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
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:
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 productionand different Google credentials, because the redirect URIs differ and one leaked development secret should not open production.The initializer
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
You do not declare the request phase. The OmniAuth middleware answers
/auth/google_oauth2before your routes are consulted.The button, and why it is not a link
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_protectionforces 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
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
By the time
createruns, OmniAuth has already validated the exchange with Google and populatedrequest.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_foris whatever your app already uses to sign someone in. On Rails 8's own authentication generator it is the method in theAuthenticationconcern.The three branches, and the one that matters
find_or_create_userabove 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_passwordvalidates presence on creation, and beyond that it keeps the forgot-password path open for someone who later loses access to their Google account.confirmed_atis 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
providerline, 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 ismicrosoft_graph, X is stilltwitter2in most gems. Andauth.info.emailis 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::RecordInvalidand afailureaction cover the two obvious paths, and a real implementation has more: the user who clicks Cancel on the consent screen, the expired or replayedstateparameter, 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_mismatchmeans 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.httpandhttpsare 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_mismatchmeans 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/callbackin Google's console, in your button and in your routes.