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.