Exactly. If you’re using Rails 8’s built-in**bin/rails generate authentication**, you don’t need Devise. I would keep Rails’ generated authentication/session layer and make Entra ID another way of establishing a Rails session.

Rails 8’s generator gives you User, Session, Current.user, SessionsController, etc.; it doesn’t provide an external identity-provider integration itself. Ruby on Rails Guides+1

The architecture I’d recommend is:

Browser
   │
   │ GET /auth/microsoft
   ▼
Rails ───────────────► Microsoft Entra ID
   ▲                         │
   │                         │ authorization code
   │                         ▼
   └──── /auth/microsoft/callback
              │
              ▼
       Find/create User
              │
              ▼
       Rails Session

For a Rails 8 server-rendered app, OmniAuth + OpenID Connect is a good fit. OmniAuth explicitly supports Rails without Devise, and Microsoft documents Ruby web-app authentication using OmniAuth/OAuth 2.0. GitHub+1

1. Start with Rails authentication

If you haven’t already:

bin/rails generate authentication
bin/rails db:migrate

You should have roughly:

app/models/user.rb
app/models/session.rb

app/controllers/sessions_controller.rb
app/controllers/passwords_controller.rb

app/models/current.rb

Keep all of that.

We’re going to add Microsoft login alongside the existing password login.

2. Add OmniAuth

In your Gemfile:

gem "omniauth"
gem "omniauth_openid_connect"
gem "omniauth-rails_csrf_protection"

Then:

bundle install

omniauth_openid_connect supports the standard authorization-code OIDC flow and discovery. GitHub

3. Register the app in Entra ID

In the Microsoft Entra admin center, create an App registration.

For a single-company application, I’d normally choose:

Accounts in this organizational directory only

You’ll get:

Application (client) ID
Directory (tenant) ID

You’ll also need a client secret.

Microsoft’s web-app flow uses OIDC, with the tenant-scoped authority:

https://login.microsoftonline.com/<tenant-id>/v2.0

and the authorization-code flow. Microsoft Learn+1

For local development, register:

http://localhost:3000/auth/entra_id/callback

For production, register your HTTPS callback as well:

https://yourapp.example.com/auth/entra_id/callback

The redirect URI must match what your application sends to Entra. Microsoft Learn

4. Configure OmniAuth

Create:

config/initializers/omniauth.rb

with:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :openid_connect,
    name: "entra_id",
    scope: %i[openid profile email],
    response_type: :code,
    discovery: true,
    issuer: "https://login.microsoftonline.com/#{ENV.fetch("ENTRA_TENANT_ID")}/v2.0",
    client_options: {
      identifier: ENV.fetch("ENTRA_CLIENT_ID"),
      secret: ENV.fetch("ENTRA_CLIENT_SECRET"),
      redirect_uri: "#{ENV.fetch("APP_URL")}/auth/entra_id/callback"
    }
end

For example:

ENTRA_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ENTRA_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ENTRA_CLIENT_SECRET=...
APP_URL=http://localhost:3000

The important part here is OIDC discovery. Microsoft publishes an OpenID configuration document containing the authorization endpoint, token endpoint, signing keys, etc., so you don’t want to hard-code all of those yourself. Microsoft Learn

5. Add the callback route

In config/routes.rb:

Rails.application.routes.draw do
  get "/auth/:provider/callback", to: "omniauth_callbacks#create"
  get "/auth/failure", to: "omniauth_callbacks#failure"

  # ...
end

Then create:

app/controllers/omniauth_callbacks_controller.rb
class OmniauthCallbacksController < ApplicationController
  allow_unauthenticated_access

  def create
    auth = request.env.fetch("omniauth.auth")

    user = User.find_or_create_by!(
      entra_id: auth.fetch("uid")
    ) do |new_user|
      new_user.email_address = auth.dig("info", "email")
    end

    start_new_session_for(user)

    redirect_to after_authentication_url
  end

  def failure
    redirect_to login_path, alert: "Microsoft sign-in failed."
  end

  private

  def after_authentication_url
    session.delete(:return_to_after_authenticating) || root_path
  end
end

There is one important Rails-8-specific detail here: use the authentication/session methods generated by Rails, rather than inventing another session mechanism.

6. Add an Entra identifier to users

I would not use the email address as the permanent identity.

Add something like:

bin/rails generate migration AddEntraIdToUsers entra_id:string

Then modify the migration:

class AddEntraIdToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :entra_id, :string
    add_index :users, :entra_id, unique: true
  end
end

Then:

bin/rails db:migrate

However, there’s a subtle point here: what should**entra_id**contain?

For a serious production application, I would normally store the Entra **oid**claim, together with the tenant identifier, rather than treating email as the identity. Microsoft tokens contain stable identity claims such as oid, while email/name claims can change. Microsoft’s documentation shows oid as an identity claim in ID tokens. Microsoft Learn

So I’d actually prefer a schema like:

add_column :users, :entra_oid, :string
add_column :users, :entra_tenant_id, :string

add_index :users,
          [:entra_tenant_id, :entra_oid],
          unique: true

Then your lookup becomes:

user = User.find_by!(
  entra_oid: auth.dig("extra", "raw_info", "oid"),
  entra_tenant_id: ENV.fetch("ENTRA_TENANT_ID")
)

Or create the user if you’re allowing automatic provisioning.

7. Add the “Sign in with Microsoft” button

For example:

<%= button_to "Sign in with Microsoft",
              "/auth/entra_id",
              method: :post %>

With current OmniAuth CSRF protection, using a POST to initiate the flow is preferable to making authentication initiation a GET. OmniAuth’s Rails documentation specifically covers CSRF protection for Rails applications. GitHub

You can put that alongside the password login Rails generated:

<h1>Sign in</h1>

<%= form_with url: session_path do |form| %>
  ...
<% end %>

<hr>

<%= button_to "Sign in with Microsoft",
              "/auth/entra_id",
              method: :post %>

8. One thing I’d change from the simple example

I would not immediately do:

User.find_or_create_by!(email_address: ...)

based solely on the email returned by Microsoft.

A safer production design is:

User
 ├── id
 ├── email_address
 └── ...

Identity
 ├── user_id
 ├── provider          # "entra_id"
 ├── uid               # Entra oid
 └── tenant_id

Then a user can eventually have:

User #42
   ├── password authentication
   └── Entra ID authentication

without coupling your application’s user identity to an email address.

For example:

class Identity < ApplicationRecord
  belongs_to :user

  validates :provider, presence: true
  validates :uid, presence: true
  validates :tenant_id, presence: true

  validates :uid,
            uniqueness: { scope: [:provider, :tenant_id] }
end

That’s the design I’d use if this is going into a real application rather than a quick internal prototype.

The key distinction

You don’t replace Rails 8 authentication with OmniAuth.

Instead:

                    ┌── password login ──┐
                    │                    │
User ── Rails 8 ────┤                    ├──► Rails Session
                    │                    │
                    └── Entra OIDC ──────┘

Rails remains responsible for your application’s authenticated session. Entra is responsible for proving the user’s identity.

That separation works particularly nicely with Rails 8’s authentication generator.

If you want, I can give you a complete Rails 8 implementation using the exact files generated by**bin/rails generate authentication**, including routes.rb, omniauth.rb, migration, callback controller, User/Identity models, login view, logout, and handling single-tenant Entra ID correctly.