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

This controller handles the link the user clicks in their email.

# app/controllers/users/magic_links_controller.rb
class Users::MagicLinksController < Devise::MagicLinksController
end

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

<!-- 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>
<!-- 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>
<!-- 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

  1. Start server: bin/dev
  2. Go to /users/sign_in
  3. Click "Sign in with a magic link"
  4. Enter a registered user's email
  5. Check Rails logs for the magic link URL (development uses log delivery)
  6. Open the link — you should be signed in
  7. 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
# 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.