OAuth is boilerplate; account linking is where it bites. Three callback cases, passwordless users, race conditions, Turbo-eats-302, credentials guard — all in one article.
Wiring OAuth into how2claude wasn't hard because of OAuth.
Installing omniauth-google-oauth2 and omniauth-github, writing an initializer, writing a callback, adding two buttons — that's a 10-minute pile of boilerplate any AI agent gets right. What's hard is account linking: if an email already has a password account, does the Google login merge? If a user already has Google linked and wants to add GitHub, how does the binding work? If two people race to first-time Google login with the same email, do you end up with two Users?
This is the real story. The initial implementation was done in Amp (91e4f48) — Amp runs on Claude underneath, with an interaction model well suited to laying down a big slab of foundation. A few days later, 0112888 in Claude Code patched a Turbo-vs-OAuth conflict. Two tools that hand off naturally.
Amp landed the boilerplate in one shot in 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount model: provider / uid / email / name / avatar_url, with unique index on [provider, uid]Auth::OmniauthController callback action/auth/:provider/callback + /auth/failuresessions/new.html.erb with two button_to entries (Google + GitHub)omniauth initializer configuring callback_pathThe User model got a one-line change — and that one line is the real trick (below).
14 files, 257 lines added. The mechanical part of OAuth ends here.
When a user clicks "Continue with Google," they eventually land on /auth/google_oauth2/callback. request.env["omniauth.auth"] holds provider, uid, email, name, avatar. You need to decide:
OauthAccount row) → sign in the already-bound UserThe code:
class Auth::OmniauthController < ApplicationController
allow_unauthenticated_access only: [:callback, :failure]
skip_forgery_protection only: :callback
def callback
auth = request.env["omniauth.auth"]
oauth_account = OauthAccount.find_by(provider: auth.provider, uid: auth.uid)
if oauth_account
start_new_session_for(oauth_account.user)
elsif (resume_session; Current.user)
Current.user.oauth_accounts.create!(oauth_params(auth))
redirect_to root_path, notice: I18n.t("auth.oauth_linked", provider: auth.provider.titleize) and return
else
user = User.find_by(email_address: auth.info.email) || User.create!(
email_address: auth.info.email,
password_digest: ""
)
user.oauth_accounts.create!(oauth_params(auth))
start_new_session_for(user)
end
redirect_to after_authentication_url
end
private
def oauth_params(auth)
{
provider: auth.provider,
uid: auth.uid,
email: auth.info.email,
name: auth.info.name,
avatar_url: auth.info.image
}
end
end
Forty-six lines that look unremarkable. Every branch hides a trap.
Rails 8's default has_secure_password requires password_digest. A user signing in with Google has no password — so what?
Don't remove has_secure_password (that breaks password login).
Do turn off the default validation and write your own conditional:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: drops the "password required" rule that ships with has_secure_passwordvalidates :password, length:, if: password.present?: if the user sets a password, enforce min length 8; otherwise don't careOAuth-only users get created with password_digest: "". They can add a password later (as long as it's 8+ chars). Password-auth users are completely unaffected.
Side effect: the existing password-reset test broke — it was using a too-short password. Amp fixed it in the same commit (test/controllers/passwords_controller_test.rb). When an agent adds a feature, glance at the test surface area — it saves one re-run cycle.
The third branch of the callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Fine single-threaded. In production, two requests arrive near-simultaneously (rare but it happens) — both find_by return nil, both create!, one succeeds, the other hits the unique index on email_address (assuming you have one).
Worse: if you don't have a unique index on email_address, two same-email Users get created. Downstream: Stripe customer binding is ambiguous, subscription lookups are wrong.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! or wrap with rescue:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
Amp's original version didn't wrap. When an agent writes high-concurrency database code, you need to actively ask "what happens under concurrency?" Agents default to assuming single-user serial access — their tests only cover serial paths.
The second branch — a logged-in user binding a new OAuth provider — looks simple, but has a bad edge case.
User A is logged in under [email protected]. Clicks Continue with GitHub. GitHub callback returns [email protected] (A's GitHub uses their work email).
Now the database may contain:
- User A: email [email protected]
- User B: email [email protected] (A registered with their work email months ago)
If you run Current.user.oauth_accounts.create!(...) as the code does, GitHub gets bound to A. But User B still exists, possibly with B's Purchases. B can still log in with password, but their GitHub is now A's.
A complete binding flow has to handle "what do we do with the orphaned account's data?" — agents don't think of this by default. At minimum, a UI warning: "This GitHub account is linked to email [email protected]. Continuing will prevent that account from using GitHub to sign in."
The current version doesn't handle this — it just binds Current.user to the OAuth record and moves on. This is an intentional simplification: patch it when the problem actually arises. But you as a human need to know the hole exists.
OAuth wired up, deployed, click Continue with Google — nothing happens. Network tab shows POST /auth/google_oauth2 → 302 to accounts.google.com/o/oauth2/auth?.... Browser doesn't move.
Prior article Letting Claude Integrate Two Payment Rails — Stripe + x402 covered the exact same mechanism: button_to generates a <form method="post">, Turbo intercepts the form, treats the response as TURBO_STREAM, and TURBO_STREAM doesn't follow cross-origin 302s.
Fix landed in 0112888 (done in Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Both OAuth buttons need it. Rule: any button_to whose target 302s to an external domain (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) needs data: { turbo: false }. Not one button — a whole class of them.
Freshly cloned repo, no GitHub credentials configured — the login page still renders "Continue with GitHub." Clicking gives Invalid client_id.
Same commit (0112888) added a guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
No GitHub credentials → no button. Worth doing because:
Getting an agent to write this kind of "credentials guard" up front doesn't come automatically — it assumes your config is always populated. Feature scaffold + credentials guard belong to two passes. Split them, don't ask for them in one prompt.
The OAuth work split into two phases:
Phase 1 (Amp, 91e4f48): one session laying down the whole OAuth slab — gems, model, migration, controller, view, routes, tests, i18n. 14 files / 257 lines / 20 minutes. Amp's interaction model suits "push forward one big chunk at a time," with clean linear thread history.
Phase 2 (Claude Code, 0112888): days later, before going live, Turbo-vs-OAuth caught us. This is pinpoint diagnosis + one-line fix + regression test territory. Claude Code has full git/session context inside the project directory, plus the project's own session-recording hook (auto-captures everything to docs/notes/pro/raw.md — see Letting Claude Write Hooks That Record Itself). The debugging trail becomes article source material.
The two tools don't conflict. Amp is for scaffolding from zero and for long threads of open discussion; Claude Code is for incremental work inside an existing project and for engineering automation. Same model underneath (Claude Opus 4.7, 1M context) — different interaction shapes and toolsets.
Same engineer, sometimes in the IDE, sometimes in the terminal. Each fits different moments.
Letting Claude (or Amp) build OAuth login — full checklist:
has_secure_password validations: false + conditional length. Don't drop password auth.button_to needs data-turbo=false. Same bug family as Stripe.OAuth itself isn't hard. What's hard is acknowledging the edge cases agents don't naturally surface — concurrency, orphan data, local-env missing credentials. Your role as a human is asking those questions: "What about concurrency? What about account orphans? What if credentials aren't set?"
You ask the questions, the agent writes the answers. That's the real division of labor in this workflow.