Magic Link Authentication — Step-by-Step Implementation Guide
A portable guide to replicate the magic link authentication from Lifehub into any Rails 7+ project using Devise.
Prerequisites
- Rails 7+ with Devise already installed and working (password-based login)
- ActionMailer configured (SMTP, SendGrid, etc.)
- Tailwind CSS (for the view templates — adapt if using something else)
Step 1: Add the Gem
# Gemfile
gem "devise-passwordless"
bundle install
No migrations needed — devise-passwordless uses stateless tokens (Rails SignedGlobalID), so there's no database table for tokens.
Step 2: Configure Devise Initializer
Add these lines at the end of config/initializers/devise.rb (before the final end):
# ==> Configuration for :magic_link_authenticatable
# Use the Passwordless mailer to send magic link emails
config.mailer = "Devise::Passwordless::Mailer"
# Token algorithm — stateless, uses Rails GlobalID (no DB storage)
config.passwordless_tokenizer = "SignedGlobalIDTokenizer"
# How long a magic link is valid after it's sent
config.passwordless_login_within = 20.minutes
Important: Setting config.mailer to Devise::Passwordless::Mailer overrides the default Devise mailer. This mailer still handles all standard Devise emails (password reset, confirmation, etc.) — it just adds the magic_link email method on top.
Step 3: Add Devise Module to User Model
Add :magic_link_authenticatable to your User model's Devise modules. It must be listed first because it overrides password_required? to return false.
If your app supports both password and magic link login (hybrid), you need to re-define password_required? so password validation stays active for normal users:
# app/models/user.rb (or a concern like app/models/concerns/user/authentication.rb)
devise :magic_link_authenticatable,
:database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
# :magic_link_authenticatable sets password_required? to false.
# Re-define it to keep password validation active for hybrid auth.
# If you also support OAuth, skip password for OAuth users.
def password_required?
return false if respond_to?(:provider) && provider.present? # skip for OAuth users
!persisted? || !password.nil? || !password_confirmation.nil?
end
If you want magic-link-only (no passwords at all), skip the password_required? override.
Step 4: Create Controllers
4a. Magic Links Controller (token verification)
This controller handles the link the user clicks in their email.
# app/controllers/users/magic_links_controller.rb
class Users::MagicLinksController < Devise::MagicLinksController
end
4b. Magic Link Sessions Controller (email form + sending)
This controller shows the email entry form and sends the magic link email.
# app/controllers/users/magic_link_sessions_controller.rb
class Users::MagicLinkSessionsController < Devise::Passwordless::SessionsController
layout "static" # or whatever layout your auth pages use
# Rate limiting (Rails 7.1+)
rate_limit to: 50, within: 3.minutes, only: :create,
with: -> { redirect_to new_user_magic_link_session_url, alert: "Too many requests. Please try again later." }
end
Step 5: Add Routes
# config/routes.rb
devise_for :users, controllers: {
registrations: "users/registrations",
sessions: "users/sessions",
passwords: "users/passwords",
magic_links: "users/magic_links", # <-- ADD THIS
# omniauth_callbacks: "users/omniauth_callbacks", # if using OAuth
}
# Magic link request form — separate from password-based sessions
devise_scope :user do
get "users/magic_link_login", to: "users/magic_link_sessions#new", as: :new_user_magic_link_session
post "users/magic_link_login", to: "users/magic_link_sessions#create", as: :user_magic_link_session
end
Routes Summary
| Method | Path | Controller#Action | Purpose |
|---|---|---|---|
| GET | /users/sign_in |
users/sessions#new |
Password login form |
| POST | /users/sign_in |
users/sessions#create |
Password login submit |
| GET | /users/magic_link_login |
users/magic_link_sessions#new |
Magic link email form |
| POST | /users/magic_link_login |
users/magic_link_sessions#create |
Send magic link email |
| GET | /users/magic_link |
users/magic_links#show |
Verify token + sign in |
Step 6: Create Views
6a. Magic Link Email Form
<!-- app/views/users/magic_link_sessions/new.html.erb -->
<div class="flex min-h-[85vh] flex-col justify-center px-4 py-12">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white rounded-2xl p-8 border border-gray-200 shadow-sm">
<div class="flex justify-center mb-4">
<div class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>
</div>
</div>
<h2 class="text-xl font-semibold text-gray-900 mb-1 text-center">Sign in with a magic link</h2>
<p class="text-sm text-gray-500 mb-6 text-center">Enter your email and we'll send you a one-time sign-in link.</p>
<%= form_for(resource, as: resource_name, url: user_magic_link_session_path, html: { class: "space-y-4" }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div>
<%= f.label :email, class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= f.email_field :email, autofocus: true, autocomplete: "email", placeholder: "[email protected]",
class: "block w-full rounded-lg border-0 py-2.5 px-3.5 text-gray-900 bg-gray-50 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-900 sm:text-sm transition-colors" %>
</div>
<div class="pt-1">
<%= f.submit "Send magic link",
class: "w-full rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-gray-900 hover:bg-gray-800 transition-all duration-200 cursor-pointer" %>
</div>
<% end %>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
Prefer to use a password?
<%= link_to "Sign in with password", new_user_session_path,
class: "font-semibold text-gray-900 hover:text-gray-700 transition-colors" %>
</p>
</div>
</div>
6b. Update Password Login Page
Add a magic link button to your existing app/views/devise/sessions/new.html.erb. Insert this after your password form's submit button:
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-200"></div></div>
<div class="relative flex justify-center text-xs">
<span class="bg-white px-3 text-gray-400">or</span>
</div>
</div>
<!-- Magic link button -->
<%= link_to new_user_magic_link_session_path,
class: "flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-50 ring-1 ring-inset ring-gray-200 hover:bg-gray-100 transition-colors" do %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Sign in with a magic link
<% end %>
Step 7: Create Email Templates
7a. Email partials (reusable)
<!-- app/views/shared/email/_action_button.html.erb -->
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 0;">
<tr>
<td style="border-radius: 6px; background-color: #374151;">
<a href="<%= url %>"
style="display: inline-block; padding: 13px 28px; color: #ffffff; font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 6px; letter-spacing: -0.01em;">
<%= label %>
</a>
</td>
</tr>
</table>
<!-- app/views/shared/email/_fallback_link.html.erb -->
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="<%= url %>"
style="color: #4b5563; word-break: break-all; text-decoration: underline;">
<%= url %>
</a>
</p>
7b. Magic Link Email (HTML)
<!-- app/views/devise/mailer/magic_link.html.erb -->
<h2 style="margin: 0 0 8px; font-size: 22px; font-weight: 700; color: #111827; letter-spacing: -0.025em;">
Your magic sign-in link
</h2>
<p style="margin: 24px 0 0; font-size: 15px; color: #374151; line-height: 1.6;">
Hello <strong><%= @resource.email %></strong>,
</p>
<p style="margin: 16px 0 0; font-size: 15px; color: #374151; line-height: 1.6;">
Click the button below to sign in to your account. No password needed.
</p>
<% action_url = magic_link_url(@resource, @scope_name => { email: @resource.email, token: @token, remember_me: @remember_me }) %>
<%= render "shared/email/action_button", url: action_url, label: "Sign in to my account" %>
<%= render "shared/email/fallback_link", url: action_url %>
<hr style="border: none; border-top: 1px solid #f3f4f6; margin: 28px 0;">
<p style="margin: 0 0 8px; font-size: 13px; color: #9ca3af; line-height: 1.6;">
This link expires in <%= Devise.passwordless_login_within.inspect %> and can only be used once.
</p>
<p style="margin: 0; font-size: 13px; color: #9ca3af; line-height: 1.6;">
If you did not request this link, you can safely ignore this email.
</p>
7c. Magic Link Email (Plain Text)
<!-- app/views/devise/mailer/magic_link.text.erb -->
Hello <%= @resource.email %>!
Click the link below to sign in to your account. The link expires in <%= Devise.passwordless_login_within.inspect %>.
<%= magic_link_url(@resource, @scope_name => { email: @resource.email, token: @token, remember_me: @remember_me }) %>
If you did not request this link, you can safely ignore this email.
Step 8: Add I18n Keys
English (config/locales/en.yml)
Add under the devise: key:
devise:
sessions:
magic_link_invalid: "Invalid or expired magic link."
mailer:
magic_link:
subject: "Your magic sign-in link"
passwordless:
not_found_in_database: "Could not find a user with that email address."
magic_link_sent: "A sign-in link has been sent to your email address. Please follow the link to sign in."
magic_link_sent_paranoid: "If your account exists, you will receive an email with a sign-in link shortly."
Add under a top-level auth: key (or wherever your custom keys live):
auth:
or: or
magic_link: Sign in with a magic link
magic_link_title: Sign in with a magic link
magic_link_desc: "Enter your email and we'll send you a one-time sign-in link."
send_magic_link: Send magic link
prefer_password: Prefer to use a password?
sign_in_with_password: Sign in with password
Portuguese (config/locales/pt-BR.yml)
devise:
sessions:
magic_link_invalid: "Link mágico inválido ou expirado."
mailer:
magic_link:
subject: "Seu link mágico de acesso"
passwordless:
not_found_in_database: "Não encontramos um usuário com esse endereço de email."
magic_link_sent: "Um link de acesso foi enviado para seu email. Por favor, clique no link para entrar."
magic_link_sent_paranoid: "Se sua conta existir, você receberá um email com um link de acesso em breve."
auth:
or: ou
magic_link: Entrar com link mágico
magic_link_title: Entrar com link mágico
magic_link_desc: Digite seu email e enviaremos um link de acesso único.
send_magic_link: Enviar link mágico
prefer_password: Prefere usar senha?
sign_in_with_password: Entrar com senha
Step 9: Security — Filter Token from Logs
Ensure :token is in your parameter filter list:
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:token]
This prevents magic link tokens from appearing in your Rails logs.
Complete File Checklist
Modified Files
| # | File | What to Change |
|---|---|---|
| 1 | Gemfile |
Add gem "devise-passwordless" |
| 2 | config/initializers/devise.rb |
Add mailer, tokenizer, and login_within config |
| 3 | app/models/user.rb (or auth concern) |
Add :magic_link_authenticatable + password_required? override |
| 4 | config/routes.rb |
Add magic_links: controller + devise_scope routes |
| 5 | app/views/devise/sessions/new.html.erb |
Add "OR" divider + magic link button |
| 6 | config/locales/en.yml |
Add devise passwordless + auth keys |
| 7 | config/locales/pt-BR.yml |
Add Portuguese translations |
| 8 | config/initializers/filter_parameter_logging.rb |
Add :token to filtered params |
New Files
| # | File | Purpose |
|---|---|---|
| 1 | app/controllers/users/magic_links_controller.rb |
Token verification (2 lines) |
| 2 | app/controllers/users/magic_link_sessions_controller.rb |
Email form + rate limiting (5 lines) |
| 3 | app/views/users/magic_link_sessions/new.html.erb |
Email entry form UI |
| 4 | app/views/devise/mailer/magic_link.html.erb |
HTML email template |
| 5 | app/views/devise/mailer/magic_link.text.erb |
Plain text email template |
| 6 | app/views/shared/email/_action_button.html.erb |
Reusable email button partial |
| 7 | app/views/shared/email/_fallback_link.html.erb |
Reusable email fallback link |
How It Works Under the Hood
User visits /users/sign_in
→ Clicks "Sign in with a magic link"
→ GET /users/magic_link_login (shows email form)
→ Enters email, submits
→ POST /users/magic_link_login
→ [Rate limited: 50 requests / 3 minutes]
→ Devise::Passwordless::SessionsController#create
→ Finds user by email
→ Generates signed token via SignedGlobalID (stateless, no DB)
→ Sends email via Devise::Passwordless::Mailer
→ User clicks link in email
→ GET /users/magic_link?user[email]=...&user[token]=...&user[remember_me]=...
→ Users::MagicLinksController verifies token
→ Signs user in via Devise session
→ Redirects to after_sign_in_path_for (dashboard)
Token Details
- Algorithm: Rails
SignedGlobalID— cryptographically signed, includes expiration - Storage: None (stateless) — no database table needed
- Expiry: 20 minutes (configurable)
- Scope: Tied to user's password hash — changing password invalidates all outstanding magic links
- Single use: Token is consumed on first use
Testing
Manual Test
- Start server:
bin/dev - Go to
/users/sign_in - Click "Sign in with a magic link"
- Enter a registered user's email
- Check Rails logs for the magic link URL (development uses log delivery)
- Open the link — you should be signed in
- Verify password login still works
Token Expiry Test
Temporarily set config.passwordless_login_within = 1.second, send a link, wait 2 seconds, click — you should see "Invalid or expired magic link."
Customization Options
Change Token Expiry
# config/initializers/devise.rb
config.passwordless_login_within = 30.minutes # or 1.hour
Disable Magic Link for Specific Users
# app/models/user.rb
def active_for_magic_link_authentication?
super && some_condition?
end
Custom Redirect After Sign-In
Magic link uses the same after_sign_in_path_for as password login:
# app/controllers/application_controller.rb
def after_sign_in_path_for(resource)
dashboard_path # or wherever
end
Paranoid Mode
By default, Devise tells the user if their email wasn't found. To prevent email enumeration, enable paranoid mode:
# config/initializers/devise.rb
config.paranoid = true
This will show magic_link_sent_paranoid message regardless of whether the email exists.