Social login
RailsFast ships one-click social sign-in as an opt-in feature: a "Continue with Google" button underneath the email form on both the sign-in and the sign-up pages, below a divider reading Or sign in with / Or sign up with.
For your users, that's a signup with no password to invent and no confirmation email to go find: Google already verified the address, so the account is created confirmed. For you, it's one more door into the same account system you already have: the same User records, the same organizations, the same post-signup funnel (a pending invitation, a pending checkout, or wherever they were before they hit the sign-in page all resume exactly as they do after a password login), and the same clickwrap evidence as the email door: a brand-new address stops at a "Finish creating your account" screen that collects an affirmative act of assent, and the account plus its evidence commit in one transaction. Returning users and already-linkable addresses stay exactly one click. See Legal documents & consent for the evidence side.
It also stays legible in your logs for free: session rows and the login trail read "Google" instead of "password" all by themselves, because the sessions gem classifies the login from the OAuth callback request itself. See Sessions & devices for that side of things.
And it's zero JavaScript. Not "degrades gracefully without it", but asks nothing of it. Every screen in the flow is server-rendered and every control is a form: with scripting disabled, the sign-in page, the sign-up page and the finish screen render and submit exactly as they do with it on, and a test in the template pins that property. The button itself is a plain button_to: a POST carrying a Rails authenticity token, which is a hard requirement rather than a style choice: starting an OAuth flow is a state-changing action, and pinning it to POST is what stops any page on the internet from kicking off a login on your users' behalf (CVE-2015-9284).
Social login is dormant by default, and the credentials are the switch, the same posture Cloudflare Turnstile takes in RailsFast. There is no gem to uncomment and no flag to set: with no Google OAuth credentials there is no strategy registered, no provider on the User model, no /users/auth/google_oauth2 route, and no button in the views. Paste the credentials and all of that appears. Blank values, and the abc123 placeholders of config/credentials.sample.yml, count as no credentials (since template v1.15.1), so a file copied from the sample never draws a button for a Google client that does not exist.
Where the button sits
Email first, always open; providers underneath, under a divider. Here's the sign-in page:
Email [ [email protected] ]
Password [ โขโขโขโขโขโขโขโข ] Forgot your password?
โ Remember me on this device
[ Sign In ]
โโโโโโโโโ Or sign in with โโโโโโโโโ
[ G Continue with Google ] โ "Last used" badge lands here
The order is a decision, and it's the one Stripe, UptimeRobot and most consumer SaaS converged on. Leading with provider buttons makes a visitor choose an identity system before they can start, and it pushes the thing almost everyone actually types behind a click. Email-first puts the field they want under the cursor on arrival, and it lets the primary submit be the only solid button on the page rather than a control that has to visually out-rank a stack of buttons above it.
Nothing is folded and nothing is hidden. There's no "Continue with email" disclosure, no progressive reveal, no step that waits for a valid address. The form is simply there. And the whole block below the divider belongs to social login: with no provider configured, the divider goes with the buttons and the page is exactly the stock email form.
The sign-up page is the same shape, with one addition: the clickwrap checkbox sits inside the form, immediately above Create Account. That placement is load-bearing rather than cosmetic; see Legal documents & consent.
Two smaller moves that come with this shape: "Forgot your password?" rides the password field's own label row instead of the page footer (it's a question about that field, and it's the one link people arrive looking for), and the footer cross-links are sentences ("Already have an account? Sign in") rather than full-width outline buttons, so they stop competing with the real controls above them.
Google first, for consumer apps
Email first is the default because it fits a SaaS audience: people arrive at a desk with a work address. A consumer app whose visitors are mostly on phones, with gmail addresses, is better served the other way round. One setting in config/railsfast/railsfast.yml flips it:
social_login_first: true
The Google button then sits above the email form, with the divider below it reading Or sign in with email / Or sign up with email. Left out, the key reads as false.
Try it before you set anything up
Judging a signup flow means walking it, and the real thing costs a Google Cloud project, a consent screen, a client and a redirect URI before you can answer "does this feel right?". So the whole flow is clickable in development with zero Google Cloud setup:
SOCIAL_LOGIN_PREVIEW=1 bin/dev
Button โ consent screen skipped โ finish screen โ clickwrap โ a real account with a real linked identity โ "Last used" badge โ Connected accounts in Settings. Only the provider's answer is fabricated; everything downstream of the callback is the real code, including the email_verified check the linking policy demands and the path that throws the access token away. Pick who you sign in as with a second variable:
SOCIAL_LOGIN_PREVIEW=1 SOCIAL_LOGIN_PREVIEW_EMAIL=[email protected] bin/dev
The fake identity is derived from that address, so the same address comes back as a returning one-click sign-in and a different one restarts the first-time signup, so both halves of the flow are walkable without restarting your head. Do this first, decide whether you want the feature, and only then spend the twenty minutes below.
Three guards keep it honest, and all three are enforced in the provider registry rather than bolted on: development only (never test, where the suite has its own mocks; never production, checked redundantly on purpose), explicit opt-in from a short list of values so SOCIAL_LOGIN_PREVIEW=off cannot read as "on", and real credentials always win: the moment google.oauth exists in credentials, preview mode is structurally incapable of activating, however the variable is set.
Turning it on
-
Go to the Google Cloud console and create a project for your app (or pick an existing one).
-
Configure the OAuth consent screen. Choose "External" as the user type so anyone with a Google account can sign in, and fill in the app name, support email, and your logo/domain. Keep the scopes to
emailandprofile: that is all RailsFast asks for, every extra scope is another line on the consent screen and another question at Google's verification review. -
Go to APIs & Services โ Credentials โ Create credentials โ OAuth client ID and pick Web application as the application type.
-
Add your authorized redirect URI:
https://YOUR-DOMAIN-HERE/users/auth/google_oauth2/callbackAdd the Google One Tap endpoint as a second redirect URI, and your site as an authorized JavaScript origin:
https://YOUR-DOMAIN-HERE/users/auth/google_one_tap (authorized redirect URI) https://YOUR-DOMAIN-HERE (authorized JavaScript origin)If you also want to use Google sign-in while developing locally, add the localhost equivalents (either to the same client, or to a second client you keep for development):
http://localhost:3000/users/auth/google_oauth2/callback http://localhost:3000/users/auth/google_one_tap http://localhost:3000 -
Copy the client ID and client secret, and paste them into your credentials:
EDITOR="cursor --wait" bin/rails credentials:editThe block goes under the environment it belongs to, exactly like the sample file ships it:
production: google: oauth: client_id: YOUR_GOOGLE_CLIENT_ID client_secret: YOUR_GOOGLE_CLIENT_SECRETAdd the same
google: oauth:block underdevelopment:(with the OAuth client whose redirect URI points atlocalhost) if you want the button in development too. If you'd rather use one single OAuth client for every environment, a top-levelgoogle:block works as well. RailsFast looks in the current environment first, then falls back to the top level. -
Restart the app.
That's the whole setup. The buttons appear on the sign-in and sign-up pages, One Tap appears on the auth pages, the OAuth routes exist, the "Connected accounts" section shows up in Settings, and nothing else in the template needs to change.
bin/railsfast doctor lists Google OAuth as optional: if you leave the sample values in place, it tells you the feature is off rather than flagging your app as misconfigured. Ship without it if you don't want it.
What RailsFast asks Google for, and why, is deliberately minimal:
- Scope
email,profile: an identity, not an API client. access_type: online: no refresh token. RailsFast never calls Google's APIs on your users' behalf, so it never asks for a long-lived credential it would have to store.prompt: select_account: people with a work and a personal Google account get to pick, every time. That single line prevents most "I signed in and it's the wrong account" support tickets.
Google One Tap
The button waits to be found. One Tap comes to the visitor: on the sign-in and sign-up pages, a signed-out visitor who is already signed in to Google in this browser gets Google's "Continue asโฆ" card. One tap signs them in, or parks a new sign-up on the same finish screen the button leads to. It uses the same Google client and the same credentials as the button, so there is nothing extra to switch on.
Which pages carry the card is your call, in config/railsfast/railsfast.yml:
google_one_tap_pages: auth # auth (the default) | all | none
auth: only the sign-in, sign-up and other account pages (forgot password, resend confirmation, unlock). Google's script has to load to draw the card, and on a page someone came to in order to sign in, they chose that.all: every signed-out page. More sign-ups from people who would never look for the button; pick it when your visitors expect it and your privacy notice covers Google on every page.none: no card and no One Tap endpoint. The button still works.
Any other value stops the app from booting. The card never appears to a signed-in visitor, inside the Hotwire Native apps, on plain HTTP other than localhost, or in preview mode.
The credential Google posts back is a signed ID token, verified locally against Google's published keys (issuer, audience = your client id, expiry, and a per-session nonce that stops login CSRF and replays), and never stored. Sessions created this way read "Google One Tap" in the login trail and the devices page. If you turn on a Content-Security-Policy, allow Google's script and frame on the pages that carry the card; config/initializers/social_login.rb lists the exact directives.
How account linking works
When someone comes back from Google, RailsFast has to answer one question: which account is this? There are exactly four possible answers, decided in this order:
- We've seen this exact Google account before. There's already a linked identity for it, so we sign that user in (and refresh the email on file, because people do change the address behind a Google account).
- New Google account, but its verified email matches an account you already have. Same person, arriving through a different door: the identity is linked to the existing account and they're signed in. This is what makes "I signed up with a password last year and I'm clicking the Google button today" just work, with no duplicate account and no support ticket.
- New Google account, nobody here with that address. The callback creates nothing. It parks the verified claim (server-side, short-lived) and sends the visitor to the finish screen, where the account is actually created. See the next section for why.
- Google did not confirm that the email address belongs to this person. Refused: no account, no link, no sign-in. The user is sent back to the sign-in page and told to sign in with their email and password instead, and to connect Google from Settings afterwards.
That fourth outcome is the security model, not an edge case. Matching on email address is exactly what makes outcome 2 possible, and it is also the classic pre-verified-email account takeover: an attacker registers an account at the provider using someone else's address, and an app that links on the address alone hands over the victim's account in one click. Google's email_verified claim is the entire defense, so RailsFast demands it before attaching an identity to a pre-existing account, and treats "no claim at all" and "no address at all" as unverified. It fails closed.
There's a mirror image of that attack, and it's closed too. If the address belongs to an account that was created but never confirmed (the classic pre-hijacking setup, where an attacker registers [email protected] with a password of their choosing and simply waits), RailsFast rotates that account's password to a random value nobody holds and marks it confirmed, because the person arriving through Google just proved they own that mailbox and the unconfirmed password-holder never did. The old password stops working, every session and remember-me cookie minted from it stops deserializing, and the rightful owner sets a real password from Settings like any other social signup.
One more refusal, specific to teams: if there's a pending organization invitation in flight and the Google account that comes back isn't the invited address, the sign-in is refused with a message naming the invited address. An invitation grants access to somebody else's organization, and the email form pins the invited address server-side. OAuth has no such lever, so it refuses rather than quietly creating an account the invitation will then decline. The pin is re-checked on the finish screen too, because the parked claim can outlive the invitation state.
The finish screen
A clickwrap is an affirmative act, and a provider button isn't one: an OAuth callback submits no form, so there's no checkbox to tick and nothing a server-side check could enforce. Rather than accept weaker assent for social users (or gate the buttons with JavaScript, which breaks with scripting off and turns one-click signup into two), the callback stops before creating anything and hands off to "Finish creating your account": one screen that names exactly which identity is about to become an account ("Creating your account as [email protected] ยท Google", the sentence that prevents the most common social-login support ticket, because people are routinely signed into two Google accounts and pick the wrong one), asks for the checkbox, and creates the account, its linked identity, and the acceptance evidence in one transaction on submit.
The clickwrap on that screen is the same control the email form renders, from the same :signup policy: one line, one checkbox:
โ I agree to the Terms and I acknowledge the Privacy Policy.
Both link straight to your own formatted /legal pages, because each document declaration in config/clickwrap.rb carries a link: naming where a person reads it. One box answers both statements, and the evidence still records two: an agreement to the Terms and an acknowledgment of the Privacy Notice, each with its own document version and its own lifecycle. The exact sentence, the exact versions, and the words on the submit button are all signed into the offer before it's rendered and rechecked when it comes back. See Legal documents & consent for what that record contains and how to change the text behind it.
The details that make it safe:
- Nothing trusts the session. The parked claim is re-run through the full linking policy before anything is written: a cookie can be replayed, and the address may have been claimed, confirmed, or linked while the page sat open. If it now resolves to an existing account, the visitor is simply signed in (that path creates nothing, so it needs no assent).
- The claim expires (2 hours), is cleared on every refusal and on sign-out, and never contains tokens: provider, uid, address, the verified flag, and the same pruned profile snapshot the identity row keeps, and nothing else.
- Abandoning is free. The one exit link leaves no trace, because nothing has been written yet. Someone who doesn't want to agree isn't trapped on a page whose only control is "agree".
- No JavaScript, and no gated buttons. The alternative (disabling the provider button until a box is ticked) breaks with scripting off and turns one-click signup into two. The buttons stay exactly one click; the affirmative act lives on a page that has a form to put it in.
- The buttons on the sign-in page are covered for free, because that Google button creates accounts too.
If you're reading the template's code: the outcome that routes here is OmniauthResult#signup_required?, which replaced the older #created?. The rename records the change this section describes: the callback no longer creates anything, so there is nothing for a created? to be true about.
What gets stored per linked account is a user_identities row: the provider, the provider's immutable ID for that person, the email they authenticated with, and a small profile snapshot (name, avatar URL, and the verified-email claim). Access and refresh tokens are never persisted, nor is the raw id_token. RailsFast doesn't call Google's APIs, so storing live credentials to someone else's Google account would buy nothing and turn every database backup and every admin screen into a token leak.
The "Last used" badge
When this browser last signed in through Google, the Google button says so: an accent outline around the button, and a small "Last used" pill notched into its top-right corner, overlapping the border so it reads as a label attached to the button rather than a second control floating near it. It appears on the sign-in and sign-up pages alike, and it's the template's one and only accent. Retint it by redefining a single CSS variable, --color-last-used, in the Tailwind entrypoint.
The badge lives on the provider button only. There's no counterpart on the email side, and that's not an omission: the email form is open on every visit, so there is nothing to reveal and nothing to nudge, and a badge on a form already in front of you tells the reader nothing they can act on. A browser whose last login was a password simply sees the page it was already going to fill in.
This is per-browser, not per-account: it's powered by the sessions gem's signed continuity cookie, which survives logout by design, so a signed-out page can honestly answer "how did this browser sign in last time?" without knowing who's looking at it. A signup through the finish screen classifies correctly too: the screen tags the session as oauth/google through the gem's documented seam, since a plain form POST carries none of the OAuth environment the classifier normally sniffs.
It costs zero JavaScript and zero configuration. It exists because the single most common social-login failure mode is a returning user who genuinely doesn't remember which button they pressed last time, creates a second account with a password, and then can't find their data.
Never render the badge inside a fragment- or page-cached block. It's per-browser by nature, so a cached "Last used" is a lie told to everybody else who gets that fragment. Nothing in the shipped auth views is cached.
Accounts without a password
An account created through Google still needs a password to satisfy Devise, so RailsFast mints a random one nobody will ever see and flags the account as password-less. That flag is the difference between "has a password" and "has a password its owner could actually type", and it drives two places where the difference matters:
-
Settings โ Password shows these users a notice and a single button: "Email me a link to set a password". It sends the same email, token, and form as a normal password reset. It just has its own action, because Devise's own reset flow exists for people locked out and bounces anyone who's signed in. The link only ever goes to the address already on the account. Once they set a real password, the flag clears itself and they can sign in either way.
-
Step-up reauthentication (the "confirm it's you" gate before sensitive actions like signing another device out) can't ask these users for a password they've never seen. So for them the page swaps the password field for a "Confirm with Google" button: a fresh round trip through an already-linked provider is the proof of identity, and they land back exactly where they were. If their provider is no longer available, the page points them at setting a password instead.
Connected accounts
Settings gets a Connected accounts card listing every provider your app offers, plus anything this user has already linked:
- Connect starts the same POST-only OAuth flow the sign-in buttons use, and brings them back to Settings when it's done. A Google account that's already connected to a different user is refused, with a message saying so.
- Connecting a brand-new provider requires recent authentication. Come back from Google without a fresh step-up and you're sent to "confirm it's you" first. A stolen signed-in session must not be able to attach the attacker's own Google account: that link is a permanent one-click door, because the callback afterwards signs in on provider + ID alone and never revisits the question. (Re-confirming an already-linked provider is the opposite case: that round trip is itself the proof, which is exactly what makes the step-up above work for password-less accounts. A newly linked identity earns no such window: it proves the visitor owns some Google account, not this one.)
- Disconnect removes the link, behind a confirmation dialog.
- Except when it would lock them out. If the account has no real password and this is the last connected provider, the Disconnect button isn't offered at all. The card says "Set a password above before disconnecting" instead. The server enforces the same rule, so the guard isn't just a UI nicety.
The whole card disappears when there's nothing to show: no providers configured and nothing linked. And a provider you switch off in credentials still lists the connections users already made, so they can always disconnect it.
Connecting is only a link: no account is created, no new session is established, and no phantom login lands in the devices page or the login trail, because the connect path never signs anybody in.
Where an account came from
Every account records how it was born, on the row itself, stamped once at creation from both doors and never rewritten:
| Column | Values |
|---|---|
users.signup_method |
password, oauth or google_one_tap: the sessions gem's own vocabulary, so "how this account signed up" and "how it signs in" are comparable words |
users.signup_provider |
the normalized alias, google, never the OmniAuth strategy name |
That split is deliberate and documented at both definitions: signup_provider speaks the login trail's language (google), while UserIdentity#provider stays the OmniAuth strategy name (google_oauth2) because it keys the routes, the callback action, and the unique index. The alias table belongs to the gem rather than being copied into the template.
Both columns are nullable, and the migration backfills existing rows to password, a statement of fact rather than a default, since social login did not exist before it. A row created outside the two product doors has no observed provenance, and inventing one would be a lie in an evidence-adjacent column.
It surfaces in the admin:
- a "Signed up via" badge on the users index and show pages, sitting next to the existing signup location so "how and where this account was born" reads in one place;
- three free index filters (signed up with a password, with OAuth, or with Google One Tap), because
signup_methodis indexed; - the user's own page lists their linked identities;
- a read-only Connected accounts resource under Security. Read-only is enforced by the routes (only index and show are drawn), not by hiding buttons: identities are minted by a provider round trip, which is the only thing that can prove one, so an admin-typed identity row would be a claim nobody verified. Not even destroy: the user's own Settings card owns disconnection, and it has the lockout guard a raw delete would skip.
Signing out is a full page load
Every browser sign-out control in the template opts out of Turbo, through one helper that carries the reasoning so nobody drops it while adding a Stimulus action. Two independent reasons, either sufficient:
- Refresh-morphing skips permanent elements. A sign-out that redirects back to the current URL is a Turbo refresh; a refresh morphs rather than replaces; and morphing leaves
data-turbo-permanentelements untouched by design. A permanent element near the session boundary therefore survives its own sign-out, still showing the dead account's data. - The snapshot cache. A Turbo-driven sign-out leaves signed-in pages in Turbo's back-navigation cache, so one Back press after logout can flash the previous account's data. A full document load drops it.
Two things are deliberately left alone: the Hotwire Native overflow menu, a generic component whose items are arbitrary, and "sign out other devices" on the devices page, which ends other sessions and is not a boundary for the current document.
More providers
The storage is already multi-provider: user_identities is one row per linked account, with a unique database index on provider + ID, so one user can collect several providers over time. Adding Apple or GitHub later is a template update plus a credentials paste: a row in the provider registry, a one-line callback action, and the credentials block. Never a migration, and never a rewrite of the linking policy, which lives in exactly one method for exactly this reason.
Hotwire Native apps don't render the button. Google refuses OAuth inside embedded WebViews, so a button there would lead your users straight to a "this browser or app may not be secure" dead end. RailsFast returns early in native shells and shows the email form only. Native social sign-in needs the platform-specific flows (ASWebAuthenticationSession on iOS, Custom Tabs on Android) and is a separate, future piece of work.
References
- Google Cloud console: https://console.cloud.google.com/
- Google Cloud, APIs & Services โ Credentials: https://console.cloud.google.com/apis/credentials
- Google Cloud, OAuth consent screen: https://console.cloud.google.com/apis/credentials/consent
- Google Identity, Using OAuth 2.0 for web server applications: https://developers.google.com/identity/protocols/oauth2/web-server
- Google Identity, Sign in with Google branding guidelines: https://developers.google.com/identity/branding-guidelines
omniauth-google-oauth2: https://github.com/zquestz/omniauth-google-oauth2omniauth-rails_csrf_protection: https://github.com/cookpad/omniauth-rails_csrf_protection- Devise, OmniAuth overview: https://github.com/heartcombo/devise/wiki/OmniAuth:-Overview