Magic links are one type of password-less authentication system. Companies like Slack use this method to provide a user-friendly and secure system. It’s user-friendly because they don’t need to store or remember another password. It’s more secure because there’s no password to hack. However, there is one minor downside if they lose access to their email, they might lose access to your website.

The process from the user’s perspective is very simple. Enter your email into the login/signup form, go to your email, click the magic link in the email, and you’re authenticated. But from a developer’s point of view, it’s a little more complicated.

This blog post will review a magic link Rails API-only implementation without any gems except for JWT. Even if you don’t use Rails, the principles will be similar for Django or Laravel. We won’t discuss Authentication after exchanging the magic link token for the access token. It’s recommended that your system utilize refresh tokens in addition to access tokens (JWTs for authentication and authorization). However, that topic is worthy of its own posts, and we won’t go over it here.

This tutorial has 2 parts:

  1. Creating the magic link
  2. Using the JWT for Authentication

JSON Web Tokens

Below are two methods for encoding access_token and magic_link_token. However, there is only one decode method because we use the same secret algorithm. We use the HMAC algorithm, but JWT supports other algorithms for cryptographic signing. The key will have two claims, exp (expiration) and iss (issuer). The issuer can be any string, but we’ve chosen a URI. There are more claims you can add depending on your situation. GitHub – jwt/ruby-jwt.

The expiration time you use for the access token will depend on whether you use refresh tokens or not. It’s recommended you do, and an appropriate expiration period would be 2 hours. If not, then you might want to choose between a week and a month.

# lib/json_web_token.rb

class JsonWebToken

  ALGORITHM = 'HS256'.freeze
  ISSUER = 'https://example.com'.freeze

  def self.access_token(user)
    payload = access_payload(user)
    JWT.encode(payload, hmac_secret, ALGORITHM)
  end

  def self.magic_link_token(email)
    payload = magic_link_payload(email)
    JWT.encode(payload, hmac_secret, ALGORITHM)
  end

  def self.decode(token)
    decoded_array = JWT.decode(token, hmac_secret, true, decode_options)
    # decoded_array => [{"email"=>"...", "iss"=>"...", "exp"=>###}, {"alg"=>"HS256"}]
    decoded_array.first
  rescue JWT::VerificationError
    nil
  rescue JWT::ExpiredSignature
    nil
  rescue JWT::InvalidIssuerError
    nil
  end

  private 

    def self.hmac_secret
      Rails.application.credentials.dig(Rails.env.to_sym :hmac_secret)
    end

    def self.access_payload(user)
      {
        user_id: user.id,
        iss: ISSUER,
        exp: 2.hours.from_now.to_i
      }
    end

    def self.magic_link_payload(email)
      {
        email: email.downcase,
        iss: ISSUER,
        exp: 10.minute.from_now.to_i
      }
    end
    
    def self.decode_options
      {
        iss: ISSUER, 
        verify_iss: true, 
        algorithm: ALGORITHM
      }
    end

end

Creating Magic Links

Let’s create a controller to send an email the magic link to the user. If your login process is more complicated, for example, if you want to check if the email already exists, log attempts, or validate the input, I recommend using a LoginForm object.

class Api::V1::LoginController < Api::V1::ApplicationController

  def create
    if email
      send_magic_link
      head :ok
    else
      render json: { errors: ["Email must be present"] }, status: :bad_request
    end
  end

  private

    def send_magic_link
      SendMagicLinkJob.perform_later(email)
    end

    def email
      params[:email].downcase
    end

end

Now let’s see JsonWebToken in action:

class SendMagicLinkJob < ApplicationJob

  def perform(email)
    token = JsonWebToken.magic_link_token(emails)
    UserMailer.send_magic_link(email, token).deliver_now
  end

end

You may have to try out a few email subject lines to ensure you don’t get sent to the Promotional tab on Gmail.

class UserMailer < ApplicationMailer
  def magic_link(email, token)
    @email = email
    @magic_link = "#{app_url}/login?token=#{token}"
    
    mail to: @email, subject: "Yodl - Magic Link for Log in"
  end
  
  private

    # You can use this to switch between urls for different environments
    def app_url
      "https://example.com"
    end
end

This is just an example, but generally, you’ll want to keep your emails on the simple side so they don’t get marked as spam.

# app/views/user_mailer/magic_link.html.erb
<p>Hello <%= @email %>,</p>
<%= link_to "Login", @magic_link %>

Using the JWT for Authentication

Finally, we need a controller to consume the magic link token and return a JWT for the user to use with the app. The payload will be nil if the token has expired or the issuer isn’t verified.

class Api::V1::SessionsController < Api::V1::ApplicationController
 
  def magic_link
    if user && user.errors.any?
      render json: { jwt: JsonWebToken.access_token(user) }, status: :created
    else
      render json: { errors: ["Magic Link was invalid"] }, status: :unauthorized
    end
  end

  private
  
    def user
      @user ||= User.find_or_create_by(email: email)
    end

    def email
      payload = JsonWebToken.decode(params[:token])
      return nil unless payload && payload["email"].present?
      payload["email"].downcase
    end

end

Notice, in the block of code, we check for user && user.errors.any?. Our magic link is effectively sign-in and sign-up. If you only want sign-in functionality, change line 14 to:

@user ||= User.find_by(email: email)

Summary of the Steps:

  1. The user enters their email to log in.
  2. Magic Link is sent to the user.
  3. The user clicks Magic Link in the email.
  4. The API exchanges the token in the Magic Link for the access token.
Author