Free

Letting Claude Build SaaS Login: Google + GitHub OAuth + Account Linking

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.


The 10-minute part

Amp landed the boilerplate in one shot in 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • OauthAccount model: provider / uid / email / name / avatar_url, with unique index on [provider, uid]
  • Auth::OmniauthController callback action
  • Routes: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb with two button_to entries (Google + GitHub)
  • 72 lines of controller tests covering 5 scenarios
  • omniauth initializer configuring callback_path

The 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.

The real problem: callback has three cases

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:

  1. Has this Google account signed in before? (matching OauthAccount row) → sign in the already-bound User
  2. Is someone currently signed in? (session has a user) → bind Google to the current User
  3. Neither? (new Google account, no session) → find by email or create new User

The 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.

Trap #1: OAuth-only users have no password

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_password
  • validates :password, length:, if: password.present?: if the user sets a password, enforce min length 8; otherwise don't care

OAuth-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.

Trap #2: the find-by-email race

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:

  • DB-level unique index: add_index :users, :email_address, unique: true
  • App-level: use find_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.

Trap #3: account binding's orphan problem

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.

Pitfall #1: Turbo eats Google's 302

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.

Pitfall #2: the GitHub button renders without credentials

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:

  1. Developers cloning the project don't see a broken button
  2. If credentials accidentally get dropped in production, no broken button to click
  3. When local and prod credentials drift, the feature degrades instead of erroring

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.

Amp + Claude Code: two tools, one model

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:

  1. Acknowledge OAuth itself is boilerplate. Focus on the three callback cases.
  2. OAuth-only users: has_secure_password validations: false + conditional length. Don't drop password auth.
  3. Add a DB unique index on email + app-layer concurrency guard for find-by-email. The agent won't.
  4. Account binding's cross-user orphan problem: UI warning at minimum. Don't pretend it doesn't exist.
  5. Every OAuth button_to needs data-turbo=false. Same bug family as Stripe.
  6. No credentials → don't render the OAuth button. Covers both dev and prod drift.
  7. Feature scaffold and credentials guard are two passes. Don't ask for them in one prompt.

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.