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.
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:
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.
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 create
action 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:
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!