Passwordless authentication via magic link is an interesting alternative to email-password authentication solutions like Devise.

passwordless-magic-link-form

A passwordless authentication flow looks like this:

  • Enter your email address
  • Receive login link in an email
  • Click link -> You are logged in

I’ve implemented passwordless authentication in insta2blog.com, and for now I am super happy with the solution 🚀. Feel free to try it out!

In a way this is a more secure authenication strategy, because there is no compromised password point of failure. It is as secure as your email account.

However to even start using this solution in production, you will need to set up sending emails in production.

It is not hard to create this kind of authentication solution on your own, however I prefer not to reinvent the wheel. Gem passwordless neatly solves the problem.

Here’s how the authentication (login) flow looks in my app:

passwordless-magic-link-flow

1. Install gem passwordless

Apart of following the official installation guide, here are some of my improvements.

# app/models/user.rb
  # add email regex validation
  validates :email,
            presence: true,
            uniqueness: { case_sensitive: false },
            format: { with: URI::MailTo::EMAIL_REGEXP }

  passwordless_with :email

  # add this so that users can be created!
  # don't add this if you want your app to be invite-only
  def self.fetch_resource_for_passwordless(email)
    find_or_create_by(email:)
  end

Requiring user only for specific actions in a controller:

# app/controllers/posts_controller.rb
  before_action :require_user!, only: %i[new create edit update destroy]

Skip user requirement for specific actions:

# app/controllers/posts_controller.rb
  skip_before_action :require_user!, only: %i[index show]

Login/Logout links:

# app/views/layouts/application.html.erb
<% if current_user %>
  <%= current_user.email %>
  <%= button_to 'Sign out', auth.sign_out_path, method: :delete, form: { data: { turbo_confirm: 'Log out?' } } %>
<% else %>
  <%= link_to 'Sign in', auth.sign_in_path %>
<% end %>

2. Update email template

Preview magic link email with Rails ActionMailer previews:

# test/mailers/previews/passwordless_mailer_preview.rb
class PasswordlessMailerPreview < ActionMailer::Preview
  # http://localhost:3000/rails/mailers/passwordless_mailer/magic_link
  def magic_link
    session = Passwordless::Session.first
    Passwordless::Mailer.magic_link(session).deliver_now
  end
end

Add better wording to the passwordless email:

<!-- app/views/passwordless/mailer/magic_link.text.erb -->
Please confirm that you want to sign in to <%= Rails.application.class.module_parent.name %>.

<%= I18n.t('passwordless.mailer.magic_link', link: @magic_link) %>

The link will expire at <%= Passwordless.timeout_at.call %>

Confirming this request will securely log you in using <%= @session.authenticatable.email %>.

This login was requested using <%= @session.user_agent %>.

If you have any issues with your account, please don't hesitate to contact me by replying to this mail.

Thanks!

Yaro from <%= Rails.application.class.module_parent.name %>

passwordless-mailer-preview

3. Open emails in development

To automaticall open previews of sent emails (so that you can confirm a magic link), you will need the gem letter_opener gem:

bundle add letter_opener
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
  config.action_mailer.delivery_method = :letter_opener
  config.action_mailer.perform_deliveries = true
  config.action_mailer.raise_delivery_errors = true

passwordless-letter-opener

4. Troubleshooting. Future development.

  • Add data: { turbo: 'false' } for the redirect from the form to work.
  • Add required: true for frontend validation of having an email present on submit.
-<%= form_for @session, url: send(Passwordless.mounted_as).sign_in_path do |f| %>
+<%= form_with model: @session, url: send(Passwordless.mounted_as).sign_in_path, data: { turbo: 'false' } do |f| %>
  <% email_field_name = :"passwordless[#{@email_field}]" %>
-  <%= text_field_tag email_field_name, params.fetch(email_field_name, nil) %>
+  <%= text_field_tag email_field_name, params.fetch(email_field_name, nil), required: true %>
  <%= f.submit I18n.t('passwordless.sessions.new.submit') %>
<% end %>

I’ve submitted these changes in a PR to passwordless. Let’s see if my changes come through.

That’s it!