bundle install black_friday_deals

Magic Links Authentication with Rails

By Exequiel Rozas

- November 20, 2024

Passwordless authentication or “magic links” authentication is a popular method of authentication nowadays.

The main drivers for its use are security and the reduction of cognitive load required from users to interact with applications in their day to day.

In this article, we will learn how to implement passwordless authentication with magic links in Rails using Devise and the devise_passwordless gems.

But, before we get to build our application let's stop to consider why and what use this auth method:

What is passwordless authentication?

It's the process of verifying a user's identity without the need for the user to use a password.

Even if we don't need a password, we still need something to prove we're who we say we are and there are a couple of ways this is done:

  • Biometric information: uses unique biological traits like voice, fingerprints, retinas or facial features to identify the user.
  • Access to a device: this includes mobile phones, hardware tokens, etc.
  • TOTP: time sensitive password/token which can be sent using an email, SMS or any other similar method. In this tutorial we implement the “magic links” flavor of TOTP.

Keep in mind that passwordless authentication can be a part of a 2-factor authentication, but it's not necessarily so because we can only implement one of the factors and call it a day.

Why use passwordless auth?

There are a number of benefits to passwordless authentication:

  • The lack of passwords itself: passwords are a common target for attacks. Having them in a database makes them vulnerable for attacks, and users often reuse passwords across services which increases their risk of being attacked even if they haven't used a service in years.
  • Better user experience: although some users don't like magic links authentication, having one less password to remember or manage reduces the initial friction to use a service.
  • Shorter sessions can improve security: users can be required to authenticate more frequently which can lead to improved security in some cases, especially if shared devices are used to authenticate.
  • Email confirmation out of the box: if the user successfully authenticates with our app, we can consider the email verified which reduces the amount of work we have to do to mitigate spam.
  • No more forgotten passwords: without the need for passwords we don't need to worry about adding a forgotten password feature to our application.

It's estimated that approximately 350 million individuals were affected by data breaches in 2023. This doesn't necessarily mean that their passwords were compromised, but the problem is nonetheless very prevalent and should be taken seriously while developing web applications.

How do magic links work?

Magic links are links that contain an expiring token that can validate a user's attempt to authenticate with our application by proving access to the email associated with the account.

The basic flow for a passwordless magic links authentication looks like this:

Passwordless login flow

There are a couple of ways we can use to verify email ownership:

  • Persisted token: generate a token with an expiration date that's associated to the user trying to authenticate, persist it to our database, send the email with that token attached. After the user clicks on the link we verify if the token is not expired and if it's actually associated with the account the user is trying to authenticate with. This is similar to the typical password recovery flow.
  • Expiring unique token: we generate a unique and signed token that is associated with the user and has an expiration date.

The first approach is not bad per se but if we can accomplish the same result without having to persist anything to the database that's probably a better outcome.

The good thing is that we can achieve our goal with Rails using the SignedGlobalID class which is capable of generating unique, signed and app-wide URIs to identify resources:

user = User.first
user_sgid = user.to_signed_global_id # short for user.to_sgid
located_user = GlobaID::Locator.locate_signed(user_sgid) # => it returns the user instance

However, the user_sgid doesn't expire by default so if we need to retrieve the user later with the same signed_global_id we could do it. This would defeat the “Timed” part of the TOTP authentication.

That's why we can add an expires_in and for params to the to_sgid method in order to namespace them to a feature and add an expiration date:

user = User.first
expiring_sgid = user.to_sgid(expires_in: 5.minutes, for: 'login') # expiring SignedGlobalID instance
located_user = GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'login') # => returns the user

The locate_signed method returns the User instance as long as the signed is valid and not expired. Otherwise, it returns nil.

So, we could actually implement the feature by sending the expiring_sgid to the user a query parameter appended to a clickable link on an email, retrieve the user if valid and authenticate it using Devise by returning the corresponding session cookie.

But that's actually how the devise_passwordless gem works under the hood, so we will not be reinventing the wheel—at least not for this tutorial—and we will be using that Devise integration to add the feature to our application.

What we will build

For this tutorial, we will build a Rails application with a home page view and signup/login views where the user can authenticate with our application and gain access to their resources in our app.

We will be using the devise gem, devise_passwordless strategy gem and the letter_opener gem to show local emails to test the authentication flow.

Login view for our magic links app

The user will be able to log in after inputting an email and clicking on the “Send magic link” button.

Please note: in order for the feature to work in production you would have to add an email provider to handle the actual email delivery.

Building magic links auth

The first thing we need to do is add the required gems to our application:

# Gemfile
gem 'devise'
gem 'devise-passwordless'

Then we install the by running bundle install.

We then install Devise and create a User model:

bin/rails generate devise:install
bin/rails generate devise User first_name last_name username slug:uniq
bin/rails generate devise:views
bin/rails db:migrate

These commands will install Devise, generate a User model, views for the typical authentication flows (sessions, registrations, passwords, confirmations, unlocks, etc.) and generate the users table needed to persist user related information.

We run the bin/rails g devise:passwordless:install which will create a mailer view for us, add some locales to devise.en.yml and, most importantly, add the following configuration to the Devise initializer:

# config/initializers/devise.rb
config.mailer = "Devise::Passwordless::Mailer"
config.passwordless_tokenizer = "SignedGlobalIDTokenizer"

These lines add a custom Devise mailer to send the magic links and sets the SignedGlobalIDTokenizer as the algorithm to tokenize the magic links.

We can also set expiration by setting config.passwordless_login_within which defaults to 20 minutes. You might want to reduce the expiration time in order for the feature to be more secure. However, that's up to you and your requirements.

Then we add the magic_link_authenticatable strategy to our User class:

# app/models/user.rb
class User < ApplicationRecord
  devise :magic_link_authenticatable
end

And we also add the corresponding Devise routes to handle the flow:

# config/routes.rb
Rails.application.routes.draw do
  devise_for :users, controllers: { sessions: "devise/passwordless/sessions" }
end

Then, we can safely remove the registration views provided by Devise and the password fields that are located in the views/devise/sessions/new.html.erb and everything in views/devise/passwords which are the views for password editing and recovery.

Removing the password related mailer views is also advisable. You can find them at views/devise/mailer and the specific files are reset_password_instructions.html.erb and password_change.html.erb.

Now, when visiting the default “new session” path at users/sign_in we should just display the form with an email input.

However, you may notice that when submitting the form nothing seems to happen, this is because the development environment doesn't deliver emails by default so let's solve that issue:

Intercepting email deliveries in development

To better test the flow and preview the emails in the browser we can intercept deliveries using the letter_opener gem.

To do so, we have to add the gem to our development group in the Gemfile:

group :development do
  gem "letter_opener"
end

After successfully running bundle install we need to set the gem as the delivery method for our development environment:

# config/environments/development.rb
config.action_mailer.perform_deliveries = true
config.action_mailer.delivery_method = :letter_opener

Now, after restarting the server, when we sign in by entering our email address, the magic link email should pop up in the browser, and we should successfully log in by clicking the link in it.

Things are looking better, but there's an issue with this flow: we can sign in, but there's no way to register a new account: if we enter an email that's not already in the database we will receive an error message claiming: “Could not find a user for that email address”.

Let's fix that:

User sign up

By default, this gem defines a single controller which is Devise::Passwordless::SessionsController and has a single createaction that looks like this:

def create
  if (self.resource = resource_class.find_for_authentication(email: create_params[:email]))
    send_magic_link(resource)
    if Devise.paranoid
      set_flash_message!(:notice, :magic_link_sent_paranoid)
    else
      set_flash_message!(:notice, :magic_link_sent)
    end
  else
    self.resource = resource_class.new(create_params)
    if Devise.paranoid
      set_flash_message!(:notice, :magic_link_sent_paranoid)
    else
      set_flash_message!(:alert, :not_found_in_database, now: true)
      render :new, status: devise_error_status
      return
    end
  end

  redirect_to(after_magic_link_sent_path_for(resource), status: devise_redirect_status)
end

As you can see, if the find_for_authentication call returns nil, which is the default behavior if the user cannot be found, the library sets the not_found_in_database or magic_link_sent_paranoid message depending on the Devise.paranoid config.

This means that, unless we define a separate registration flow, there's no way for users to sign up to our application.

So, we will see how to solve this in two ways: registering users when the email provided doesn't seem to have an account and by defining a separate sign up flow:

Registering a user if not found

This approach involves using the same form we use to handle user sign in to also register new accounts if a user with the provided email can't be found.

To accomplish this, we need a controller that inherits from the Devise::Passwordless::SessionsController and overrides its behavior.

We can use the controllers generated by Devise to achieve this:

bin/rails generate devise:controllers users

This will create a set of controllers inside app/controllers/users which are commented out by default and implement the default Devise behavior.

Then, we change the configuration in the routes.rb file to associate our newly added controllers with the route:

# config/routes.rb
devise_for :users, controllers: { sessions: "users/sessions" }

Now, we have to modify the Users::SessionsController create action:

# app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::Passwordless::SessionsController
  def create
    user = User.find_or_initialize_by(email: params[:user][:email])
    if user.new_record?
      user.save
      user.send_magic_link
      flash[:notice] = "We've sent you a magic link to complete your registration. Please check your email."
      redirect_to root_path
    else
      super
    end
  end
end

Now, if the user provided email matches to an existing user we just inherit the default behavior but if the user doesn't exist we save it to the database, send the magic link and inform the user via a flash message that we emailed the provided address.

This means that we can use a single “Sign in” page to authenticate existing users and register new ones when applicable.

The other approach is to use a separate sign up page:

Using a separate sign up page

We can use a separate sign up page in case we want to have separate flows for each one of them.

To achieve this, the first step is to add the registerable strategy to our User class:

# app/models/user.rb
class User < ApplicationRecord
  devise :magic_link_authenticatable, :registerable
end

This will define the sign-up route at users/sign_up, now we have to tell Devise to use the registrations_controller in the routes file:

# config/routes.rb
Rails.application.routes.draw do
  devise_for :users, 
    controllers: { 
      sessions: "users/sessions",
      registrations: "users/registrations"
    }
end 

For the sign-up form, we will be adding a username field to the email:

Sign up form for magic links auth

Then, we have to modify the Users::RegistrationController that was created when we ran the bin/rails generate devise:controllers users, we want to send the magic link to the user using the send_magic_link method:

# app/controllers/users/registrations_controller
class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]

  def create
    super do |user|
      if user.persisted?
        user.send_magic_link
        flash[:notice] = "We've sent you a magic link to complete your registration. Plese check your email."
        return redirect_to root_path
      end
    end
  end

  protected

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :email])
  end
end

As you can see, we modified the controller to send a magic link to every newly registered user, we also populate the flash hash with a message telling the user about the email, and then we redirect them to the root_path.

The use of the return is not casual: we need to use it because if we don't, we will get a duplicate render error because Devise also redirects or renders a new view.

We can customize the behavior as much as we need, but the code above shows a basic flow where we can register new accounts with the typical Devise sign-up flow and validate them using a magic link email.

Luckily, this technique can be complemented by using other authentication techniques like OAuth login with Rails because, ultimately, we don't need to keep track of the password for the user.

The result for this flow should look something like this:

We can register a new user account using a separate flow if we like. Of course, we can customize the create action to our liking in case we require any extra behavior.

Summary

Passwordless login using magic links is a practical and arguably more secure way to handle user authentication for Rails applications.

Implementing the feature by ourselves is doable, but we can achieve the functionality faster by using a gem like devise-passwordless.

The gem uses Rails to_signed_global_id which is included in Rails models to generate a unique, global and expiring token that is associated with the user and then use the GlobalID::Locator.locate_signed method to retrieve the user associated with the token if the token is valid and not expired.

Because the gem doesn't handle user signups in the login form, we have to implement the sign-up logic by overriding Devise default behavior and sending the magic link there so newly registered users can sign in.

All in all, adding magic links auth to a Rails app is straight-forward with devise-passwordless we can also add extra functionality like multimodel passwordless login, combining password and passwordless authentication, using a custom tokenizer, among others.

I hope the article helps you implement this feature in your applications. Happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.