How to Implement Passwordless Authentication in Ruby on Rails

We recently discussed the benefits of passwordless authentication. Now let's take a look at how to implement passwordless authentication in Ruby on Rails

Frameworks and Languages

In the previous article I discussed the benefits of passwordless authentication. In this article I'm going to demonstrate how to implement magic sign in link emails in Ruby on Rails. For those unfamiliar, magic login links allow a user to log into their account without entering a password. Instead of the traditional email and password form, the user only needs to enter their email address. The system will then send a link to the user which contains a token linked to their account. When the user clicks the link they will be taken to the application and seamlessly logged into their account. With this technique we can even log them into native applications through the usage of deeplinks.

Let's Write Some Code


The first thing we need to do is add a new column to our Users to store the login tokens. We'll use UUIDs to make generation simpler.

rails g migration add_login_token_to_users login_token:uuid

Now let's create a controller to handle the log ins. This controller won't be strictly RESTful as we can't use a post request from the users email client. I normally use magic_logins_controller so we can also reserve sessions controller for password logins should we ever want to have both. 

class MagicLoginsController < ApplicationController
  def new; end

  def create
    if (user = User.find_by(login_token_params))
      user.generate_login_token!
      LoginMailer.with(user: user).magic_login_email.deliver_later
      redirect_to(new_magic_login_path, notice: 'Please check your inbox')
    else
      redirect_to(new_magic_login_path, alert: 'It looks like that email is not registered')
    end
  end

  private def login_token_params
    params.require(:login_token).permit(:email)
  end
end

Rails.application.routes.draw do
  resources :magic_logins, only: %i[new create]
end

Let's add a view for the new action. I'm not going to style this, you can style however you like, we'll simply add a form for an example.

<%= form_for(User.new, url: magic_logins_path, method: :post do %>
  <%= f.label :email %>
  <%= f.email_field :email %>
  <%= f.submit 'Send me a magic login link' %>
<% end %>

Next we need to add a method to generate a new token to our User model.

class User < ApplicationRecord
  def generate_login_token!
    update_column(:login_token, SecureRandom.uuid)
  end
end

We don't need to validate anything here as we're using UUIDs so we should be safe. Ok, so next, let's create our magic_login_email.

class LoginMailer < ApplicationMailer
  def magic_login_email
    user = params[:user]
    @magic_login_url = magic_logins_url(token: user.login_token)
    mail(to: user.email, subject: "Here's your login link")
  end
end

<h1>Here's your login link</h1>
<%= link_to 'Log in now', @magic_login_url %>

You might have noticed we're using the index url and passing the token as a query string. You can do this other ways but this approach is simple and fits in with what Rails already offers. It does of course violate RESTful principles so you might prefer to use another approach. 

Now we just need to add the logic to sign in the users. I'm going to assume you're using Devise. If you're using another gem or are managing sessions manually you'll need to adjust this logic.

class MagicLoginsController < ApplicationController
  def index
    redirect_to root_path unless params[:token].present?

    user = User.find_by(login_token: params[:token])
    if user
      sign_in(user)
      user.update_column(:login_token, nil)
      redirect_to after_sign_in_path, notice: 'Logged in successfully!'
    else
      redirect_to new_magic_login_path, alert: 'The link you used was invalid. Please request a new login link'
    end
  end
end

As you can see we are signing in the user and then removing the login token from the database. This prevents the token from being used more than once. Everything is all hooked up and users can now login using magic links. You may also like to restrict how long the tokens are valid for. To do this we can simply add another column to track when the token was created and verify it hasn't expired

rails g migration add_login_token_generated_at_to_users login_token_generated_at:datetime


class MagicLoginsController < ApplicationController
  def index
    redirect_to root_path unless params[:token].present?

    user = User.find_by(login_token: params[:token])
    if user
      if user.login_token_generated_at < 15.minutes.ago
        redirect_to new_magic_login_path, alert: 'The link you used has expired. Please request a new login link'
      end
      sign_in(user)
      user.update_column(:login_token, nil)
      redirect_to after_sign_in_path, notice: 'Logged in successfully!'
    else
      redirect_to new_magic_login_path, alert: 'The link you used was invalid. Please request a new login link'
    end
  end
end

Rails.application.routes.draw do
  resources :magic_logins, only: %i[index new create]
end

That's all there is to it. It's really quite simple to implement.

Looking for a new challenge? Join Our Team

Like 4 likes
Joe Woodward
I'm Joe Woodward, a Ruby on Rails fanatic working with OOZOU in Bangkok, Thailand. I love Web Development, Software Design, Hardware Hacking.
Share:

Join the conversation

This will be shown public
All comments are moderated

Comments

Nico
April 22nd, 2021
Thanks for this good written article !
However I'm still puzzled : what if someone intercept the email (or whatever, forwarded, click on someone else's device...) ?

Should'nt we manage a two part token ? First part sent by email on request, while the other part is part of request response and therefore stored on the device the request has been made ?

Joe Woodward
April 26th, 2021
Hey Nico, thanks for the comment.

This article just fleshes out the basics, you can take this in any direction you like. Email in insecure so you're right, certain measures can be taken to reduce the risks of exposing an account to malicious actors. For example, a lot of apps will limit the validity of a login token to 15 minutes.

However, if a users email account is compromised there's not much we can do to protect the user. In this case, even password based logins are just as vulnerable due to password resets emails. After generating a password reset, or a passwordless login, a malicious actor can change the email to their own address and then they have full control of the account. If this is a serious concern for your application then you should either be doing identity verification for important account changes, or allow your users to reclaim their accounts with some form of identity verification.

It is possible to manage a token's audience with Rails' MessageEncryptor now, however, if you restrict a token to a specific device or browser you are restricting the UX so it's important to make these choices deliberately. For example, a user may be logging in on their mobile device but generating a login email from their computer. Maybe they find that flow easier, who knows.

Joe Woodward
April 26th, 2021
To add to the above. If security is important another option is two factor authentication. This would require a malicious actor to compromise both the email and the 2fa generator for an account. I have written an article one how to implement one-time-password two-factor-authentication in rails here https://oozou.com/blog/otp-2fa-in-ruby-on-rails-with-rotp-42

Alex
July 20th, 2021
Hey thanks for the article! It would also be nice if you could explain how to test it!

Joe Woodward
July 20th, 2021
Hey Alex,

Should be easy to test with an integration test. https://guides.rubyonrails.org/testing.html#integration-testing

You’ll need to trigger the sign in email, then find the email in the outgoing messages, click on the link inside the email and then verify you are successfully logged in.

I recommend reading through the link I posted above and also search through stack overflow for similar tests. I’ll try and find time to edit the blog and add more details if I can

Tala
December 3rd, 2021
Thanks for this article! A few things tripped me up:

1) the index action in the MagicLoginsController was never created in the routes file (only :new and :create were);

2) what's generating new_login_token_path and after_sign_in_path?

3) how does it all start? are (prospective) users visiting /magic_logins/new ? (just an empty action without template at present)

I realize this wasn't meant to be a complete tutorial but I'd love to see it fleshed out a bit more.

Joe Woodward
January 14th, 2022
Hi Tala,

Thanks for commenting. Looks like I missed/messed-up a couple of details

1) the index action in the MagicLoginsController was never created in the routes file (only :new and :create were);

Thanks, I overlooked this and have updated the article.

2) what's generating new_login_token_path and after_sign_in_path?

new_login_token_path is a typo, should be new_magic_login_path
after_sign_in_path is from Devise, it's widely used for handling user sessions so didn't explain this.

3) how does it all start? are (prospective) users visiting /magic_logins/new ? (just an empty action without template at present)

The new page is similar to a normal login page except instead of having email/username and password, you'd just have the email/username field.
I've added an example to the article.

Tala
March 8th, 2022
Hi Joe,

Thanks for the updates! A few other (small) things I noticed:

- the view for the new action won't work as is, it needs to be:

<%= form_for(User.new, url: magic_logins_path, method: :post) do |f| %>

- the index action of the MagicLoginsController gives a double redirect error, so need to return from the first one:

redirect_to root_path and return unless params[:token].present?

- login_token_params didn't work, I changed to:

params.require(:user).permit(:email)


Get our stories delivered

From us to your inbox weekly.