DEV Community

Cover image for Implementing "Linked Accounts" in a Rails App with Devise and OmniAuth
Jess Alejo
Jess Alejo

Posted on

Implementing "Linked Accounts" in a Rails App with Devise and OmniAuth

So, I decided to add a Linked Accounts feature to my project. Why? Because I thought, "Wouldn't it be great if users could log in with Google or Facebook and not have to remember another password?" Then I realized: I also needed a way to link these accounts so users could switch between them seamlessly. Here’s how that journey went.

Why Even Bother with Linked Accounts?

  1. Seamless Authentication – Less password hassle. Users love convenience.

  2. Account Recovery — If they forget their password, it's no problem! They can log in with another linked provider.

  3. Security – Fewer passwords means fewer weak spots for hackers.

Step 1: Install Devise & OmniAuth

I have Devise already installed. So I just need to add OmniAuth for Google and Facebook and a library to handle CSRF protection.

gem "devise"
gem "omniauth-google-oauth2"
gem "omniauth-facebook"
gem "omniauth-rails_csrf_protection"
Enter fullscreen mode Exit fullscreen mode
  1. devise: A flexible and popular authentication solution for Rails applications. It provides features like user registration, login, password recovery, and account management.

  2. omniauth-google-oauth2: An OmniAuth strategy for authenticating users via Google OAuth 2.0, enabling social login through Google accounts.

  3. omniauth-facebook: An OmniAuth strategy for authenticating users via Facebook, allowing users to log in to the application using their Facebook credentials.

  4. omniauth-rails_csrf_protection: Provides CSRF (Cross-Site Request Forgery) protection for OmniAuth in Rails applications, ensuring secure authentication flows.

Step 2: Update Devise Configuration

I edited config/initializers/devise.rb, and added OmniAuth providers:

config.omniauth :google_oauth2,
                ENV["GOOGLE_CLIENT_ID"],
                ENV["GOOGLE_CLIENT_SECRET"],
                scope: "email,profile"

config.omniauth :facebook,
                ENV["FACEBOOK_APP_ID"],
                ENV["FACEBOOK_APP_SECRET"],
                scope: "email"
Enter fullscreen mode Exit fullscreen mode

Step 3. Set Up Environment Variables

I added the credentials to .env (you may use Rails credentials)

GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
FACEBOOK_APP_ID=your-facebook-app-id
FACEBOOK_APP_SECRET=your-facebook-app-secret
Enter fullscreen mode Exit fullscreen mode

I followed these steps to set up my Google client API credentials:

  1. Log in to Google Cloud Console.
  2. Create a New Project named it after my project name
  3. Enable the OAuth API and generate new Client ID & Secret under that project.
  4. Set the OAuth consent screen and provide the email and domain.

I haven't done setting up the Facebook OAuth yet, but I think the process will be similar.

Step 4: Update Database Schema

Having a user model wasn’t enough. I needed a way to store multiple authentication methods for a single user. Enter: UserIdentity.

1. Update Database Schema

Run the migration to create user_identities:

rails generate model UserIdentity user:references provider:string uid:string
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

2. Update Models

User Model app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: %i[google_oauth2 facebook]

  has_many :user_identities, dependent: :destroy

  def self.from_omniauth(auth)
    identity = UserIdentity.find_by(provider: auth.provider, uid: auth.uid)

    if identity
      identity.user
    else
      user = find_or_create_by(email: auth.info.email) do |u|
        u.password = Devise.friendly_token[0, 20]
      end

      user.user_identities.create(provider: auth.provider, uid: auth.uid)

      user
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

UserIdentity Model app/models/user_identity.rb

class UserIdentity < ApplicationRecord
  belongs_to :user
  validates :provider, :uid, presence: true, uniqueness: { scope: :provider }
end
Enter fullscreen mode Exit fullscreen mode

Step 5. Create OmniAuth Callbacks Controller

I created an OmniauthCallbacks controller using the rails generator

rails generate controller users/omniauth_callbacks
Enter fullscreen mode Exit fullscreen mode

Then edited app/controllers/users/omniauth_callbacks_controller.rb:

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def google_oauth2
    handle_auth("Google")
  end

  def facebook
    handle_auth("Facebook")
  end

  private

  def handle_auth(kind)
    @user = User.from_omniauth(request.env["omniauth.auth"])

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication
      flash[:notice] = "Successfully signed in with #{kind}!"
    else
      redirect_to new_user_registration_url, alert: "#{kind} login failed."
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 6. Update Routes

I need the configure the routes for omniauth callbacks, so in the routes.rb file:

devise_for :users, controllers: {
  omniauth_callbacks: "users/omniauth_callbacks"
}
Enter fullscreen mode Exit fullscreen mode

Step 7. Add Login Buttons to the Sign in Page

Then, to make this feature visible to users, I added these social media buttons in the app/views/devise/sessions/new.html.haml:

-# / Social-media btn
.text-center
  %p Sign in with your social network for quick access
  %ul.list-unstyled.justify-content-center.m-0
    %li.mb-2
      = button_to user_facebook_omniauth_authorize_path,
                  class: "btn bg-facebook w-100",
                  "data-turbo" => false do
        %i.fab.fa-facebook-f.me-2
        Sign in with Facebook
    %li
      = button_to user_google_oauth2_omniauth_authorize_path,
                  class: "btn bg-google w-100",
                  "data-turbo" => false do
        %i.fab.fa-google.me-2
        Sign in with Google
Enter fullscreen mode Exit fullscreen mode

Note that I need to add data-turbo="false" to the OAuth buttons because Turbo Drive (from Hotwire) automatically intercepts form submissions and link clicks, replacing the <body> content via AJAX instead of performing a full page reload.

Why is this a problem for OAuth?

  1. OAuth requires a full redirect – When you click "Sign in with Google" or "Sign in with Facebook," your browser must fully redirect to the OAuth provider's site (Google/Facebook).
  2. Turbo prevents the full reload – Instead of navigating away, Turbo tries to handle the request with AJAX, but OAuth doesn’t work that way. The provider won’t allow a response inside a Turbo AJAX request.
  3. Adding data-turbo="false" forces a full-page load – This ensures that clicking the button actually redirects to the authentication provider as expected.

Step 8. Add "Linked Accounts" Section in Profile

Users should see which accounts are linked and be able to unlink them. Since the "Linked Accounts" section contains different styles for each linked account, I used ViewComponent to organize things. But this example will give you an idea of how to implement it without extra stuff:

%h3 Linked Accounts

- @user.user_identities.each do |identity|
  %div
    = identity.provider.capitalize
    = button_to "Unlink",
                unlink_account_profile_path(id: identity.id),
                method: :delete,
                class: "btn btn-danger"

%h4 Link New Account
= link_to "Connect Google",
          user_google_oauth2_omniauth_authorize_path,
          class: "btn btn-danger"

= link_to "Connect Facebook",
          user_facebook_omniauth_authorize_path,
          class: "btn btn-primary"

Enter fullscreen mode Exit fullscreen mode

Step 9. Add "Unlink Account" Feature

For the unlinking to happen, I updated the ProfileController and created an unlink_account action:

class ProfileController < ApplicationController
  before_action :authenticate_user!

  def unlink_account
    identity = current_user.user_identities.find(params[:id])

    if identity
      identity.destroy
      flash[:notice] = "#{identity.provider.capitalize} account unlinked!"
    else
      flash[:alert] = "Account not found."
    end

    redirect_to profile_path
  end
end
Enter fullscreen mode Exit fullscreen mode

And to make that action accessible via URL:

  resource :profile, as: :profile, controller: "profile", only: %i[show edit update] do
    # ...
    delete :unlink_account
  end
Enter fullscreen mode Exit fullscreen mode

Step 10. Test

  1. Try signing in with Google.
  2. Check if the Google account is correctly linked in the profile section.
  3. Unlink a provider and ensure you can still log in with an alternative method.

Final Thoughts: What I Learned 🚀

  • Linking accounts is easy conceptually but requires a good schema.
  • Setting up API client credentials seems daunting at first.
  • Having multiple login options is super user-friendly!
  • Devise and OAuth libraries are awesome.

So, now users can log in with Google or Facebook, link multiple accounts, and unlink them when needed. I’m proud of this feature, and it definitely improves the user experience. Should I add Twitter next? Maybe. 🤔

Top comments (0)