Authentication is one of the key aspects of many web applications. It is the process of identifying a person before granting them access to the application. It is very important that the authentication approach is secure and easy to use for all users.

What is passwordless authentication?

Passwordless authentication is a verification process that determines whether an individual is who they claim to be, without coercion. You do not require credentials to log in. All you need is an email address or phone number associated with an account and you will get a magic link or one-time password each time you want to log in. Once you click on the link, you will be redirected to the application and you will already be logged in. After that, the magic link or one-time password will no longer be valid and no one else can use it.

There are several ways of implementing passwordless authentication.

  • Magic Link
  • SMS/Email One Time Pin Code
  • Biometric Verification

In this article we will be focusing on Passwordless Authentication using Magic Link.

What are the Benefits of Passwordless Authentication?

Improve user experiences – Users do not have to remember and manage usernames & passwords for different applications.

Strengthen security – Applications do not have the overhead of storing the credentials. This also eliminates data breaches, password thefts, brute-force attacks and other password-hacking attempts.

Recently I worked on an application that allowed a user to share the contract links with other users. I had to track the identity(email) of the user who accessed the shared link without introducing the traditional approach of email/password. So I got an opportunity to implement Passwordless Authentication via Magic Link. I'll be sharing the implementation in this article. The entire example code used in this article is available on GitHub, feel free to fork and play with it.

The Application which I worked on, uses the API to serve the data to a Client application. The diagram below explains the User-Client-API-Email flow.

Passwordless-Authentication-via-Magic-Link

Rails API application

  1. Create Rails API App

    $ rails new passwordless-authentication-api --api
    $ cd passwordless-authentication-api
    
  2. Add User Model

    $ rails g model user name email:uniq login_token login_token_verified_at:datetime
    $ rails db:create && rails db:migrate
    
    • name field is optional
    • email field is required and unique, which enables users to authenticate with the application.
    • login_token field is used to store the token which is shared with the user to verify their identity.
    • login_token_verified_at field for storing the time when the user proves their identity.

    Let's add validation for the email field.

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

    Also, let's downcase the emails before saving them to the database.

    # app/models/user.rb
    before_save { self.email = email.downcase }
    
  1. Integrate jwt Gem to generate token
    We will be using the jwt Gem to generate the token. Refer to Authenticate Your Rails API with JWT from Scratch for a thorough explanation.

    Let's add jwt Gem to Gemfile and run bundle install.

    # Gemfile
    gem 'jwt'
    

    Once done, create a file called json_web_token.rb under lib and add following code snippet:

    # lib/json_web_token.rb
    require 'jwt'
    
    class JsonWebToken
      # Encodes and signs JWT Payload with expiration
      def self.encode(payload)
        payload.reverse_merge!(meta)
        JWT.encode(payload, Rails.application.secrets.secret_key_base)
      end
    
      # Decodes the JWT with the signed secret
      def self.decode(token)
        JWT.decode(token, Rails.application.secrets.secret_key_base)
      end
    
      # Validates the payload hash for expiration and meta claims
      def self.valid_payload(payload)
        !(expired(payload) || payload['iss'] != meta[:iss] || payload['aud'] != meta[:aud])
      end
    
      # Default options to be encoded in the token
      def self.meta
        {
          exp: 1.day.from_now.to_i,
          iss: 'issuer_name',
          aud: 'client',
        }
      end
    
      # Validates if the token is expired by exp parameter
      def self.expired(payload)
        Time.at(payload['exp']) < Time.now
      end
    end
    
  1. Add an endpoint to accept the user's email and send them a magic link to access the application.

    $ rails g controller api/v1/authentication
    

    Add the below routes

    # config/routes.rb:
    namespace :api do
      namespace :v1 do
        post 'authentication/create'
      end
    end
    
    # app/controllers/api/v1/authentication_controller.rb
    class Api::V1::AuthenticationController < ApplicationController
      def create
        # the user might already exist in our db or it might be a new user
        user = User.find_or_create_by!(email: params[:user][:email])
    
        user.send_magic_link
    
        head :ok
      end
    end
    
    # app/models/user.rb
    def send_magic_link
      generate_login_token
    
      UserMailer.magic_link(self, login_link).deliver_now
    end
    
    # generates login token to authorize user
    def generate_login_token
      # create a login_token and set it up to expire in 60 minutes
      payload = {
        email: email,
        exp: 1.hour.from_now.to_i
      }
      # set login_token to validate last sent login token
      self.login_token = generate_token(payload)
      save!
    end
    
    # returns the magic link which is to be included in the email
    def login_link
      Rails.application.routes.url_helpers.api_v1_sessions_create_url(login_token: login_token, host: 'localhost:3000')
    end
    
    private
    
    def generate_token(token_payload)
      JsonWebToken.encode(token_payload)
    end
    

    UserMailer to send a magic link to the user's email

    # app/mailers/user_mailer.rb
    class UserMailer < ApplicationMailer
      def magic_link(user, login_link)
        @user = user
        @login_link  = login_link
        mail to: @user.email, subject: 'Sign In to mywebsite.com'
      end
    end
    
    # app/views/magic_link.html.erb
    <p>Hello <%= @user.email %>,</p>
    <%= link_to "Confirm", @login_link %>
    
  2. Add sessions endpoint to verify the magic link and return the auth token. Then the client application can use the auth token to access authenticated API endpoints.

    $ rails g controller api/v1/sessions
    

    Add the below routes

    # config/routes.rb:
    namespace :api do
      namespace :v1 do
        post 'sessions/create'
      end
    end
    
    # app/controllers/api/v1/sessions_controller.rb
    class Api::V1::SessionsController < ApplicationController
      def create
        login_token = params[:login_token].to_s
        decoded_token = JsonWebToken.decode(login_token)
    
        if decoded_token && JsonWebToken.valid_payload(decoded_token.first)
          user = User.find_by(login_token: login_token)
          if user
            render json: { auth_token: user.generate_auth_token }
          else
            render json: { error: 'Invalid Request' }, status: :unauthorized
          end
        else
          render json: { error: 'Invalid Request' }, status: :unauthorized
        end
      end
    end
    
    # app/models/user.rb
    # generates auth token to authenticate the further request once user is authorized
    def generate_auth_token
      self.login_token = nil
      self.login_token_verified_at = Time.now
      self.save
    
      payload = {
        user_id: id,
        login_token_verified_at: login_token_verified_at,
        exp: 1.day.from_now.to_i
      }
    
      generate_token(payload)
    end
    
  3. Add logic to verify the token before accessing the request

    # app/controllers/application_controller.rb
    class ApplicationController < ActionController::API
    
      private
      # Validates the token and user and sets the @current_user scope
      def authenticate_request!
        if !payload || !JsonWebToken.valid_payload(payload.first)
          return invalid_authentication
        end
    
        load_current_user!
        invalid_authentication unless @current_user
      end
    
      # Returns 401 response. To handle malformed / invalid requests.
      def invalid_authentication
        render json: { error: 'Invalid Request' }, status: :unauthorized
      end
    
      private
    
      # Deconstructs the Authorization header and decodes the JWT token.
      def payload
        auth_header = request.headers['Authorization']
        token = auth_header.split(' ').last
        JsonWebToken.decode(token)
      rescue
        nil
      end
    
      # Sets the @current_user with the user_id from the payload
      def load_current_user!
        @current_user = User.find_by(id: payload[0]['user_id'])
      end
    end
    

    We can add the authenticate_request! method in the before_action of any controller, for the actions that we want to be authenticated.

    Now, we have added the authentication layer to the application.

  4. Add contract endpoints for which we need users to be authorized.

    $ rails g model contract title terms:text
    

    Let's add API to return the contract details.

    $ rails g controller api/v1/contracts
    
    # config/routes.rb:
    namespace :api do
      namespace :v1 do
        resources :contracts, only: :show
      end
    end
    
    # app/controllers/api/v1/contracts_controller.rb
    class Api::V1::ContractsController < ApplicationController
      before_action :authenticate_request!
    
      def show
        @contract = Contract.find(params[:id])
        render json: @contract
      end
    end
    
  1. Now, let's explore the endpoints with curl

    First, check the response for contracts API without authentication

    $ curl -X GET http://localhost:3000/api/v1/contracts/1
    {
       "error" : "Invalid Request"
    }
    

    As expected, since we did not pass the token, it returned an error message.

    Now, let's authenticate ourselves in order to access the contracts API.

    First, we will need to provide an email address where we can receive the verification email.

    $ curl http://localhost:3000/api/v1/authentication/create  -H 'content-type: multipart/form-data' -F 'user[email]=sampat.badhe@kiprosh.com'
    

    For this example, I am checking email content from the logs.

    Delivered mail 60bd9e6b4368c_bcf346c709f7@Sampat.local.mail (8.3ms)
    Date: Mon, 05 Jun 2021 09:49:55 +0530
    From: passwordless_authentication@example.com
    To: sampat.badhe@kiprosh.com
    Message-ID: <60bd9e6b4368c_bcf346c709f7@Sampat.local.mail>
    Subject: Sign in into mywebsite.com
    Mime-Version: 1.0
    Content-Type: text/html;
     charset=UTF-8
    Content-Transfer-Encoding: 7bit
    
    <!DOCTYPE html>
    <html>
      <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <style>
        </style>
      </head>
    
      <body>
        <p>Hello sampat.badhe@kiprosh.com,</p>
    <a href="http://localhost:3000/api/v1/sessions/create?login_token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMwNDMxOTUsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwiZW1haWwiOiJzYW1wYXQuYmFkaGVAZXhhbXBsZS5jb20ifQ.uWG-Nl8wxiI75OcK8asdHO-mTTm5JMZapB0GqqpAwSY">Confirm</a>
    
      </body>
    </html>
    

    Now, as we have received the email, let's access the magic link URL using curl.

    $ curl -X POST http://localhost:3000/api/v1/sessions/create?login_token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMwNDMxOTUsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwiZW1haWwiOiJzYW1wYXQuYmFkaGVAZXhhbXBsZS5jb20ifQ.uWG-Nl8wxiI75OcK8asdHO-mTTm5JMZapB0GqqpAwSY
    {
       "auth_token" : "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMxMjY0MzYsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwidXNlcl9pZCI6MiwibG9naW5fdG9rZW5fdmVyaWZpZWRfYXQiOiIyMDIxLTA2LTA3IDA0OjI3OjE2IFVUQyJ9.Z3eZSxJsWYpLLvSNFVggwYzPQ-oqoBkfJBXgVXQcEXc"
    }
    

    Once the magic link is verified, we will get an auth_token which can be stored by the Client applications to access authenticated endpoints.

    Now, let's access the contracts API again with auth_token in the headers.

    $ curl http://localhost:3000/api/v1/contracts/1 -H 'Authorization: eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMxMjY0MzYsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwidXNlcl9pZCI6MiwibG9naW5fdG9rZW5fdmVyaWZpZWRfYXQiOiIyMDIxLTA2LTA3IDA0OjI3OjE2IFVUQyJ9.Z3eZSxJsWYpLLvSNFVggwYzPQ-oqoBkfJBXgVXQcEXc'
    {
       "created_at" : "2021-06-04T11:57:15.590Z",
       "id" : 1,
       "updated_at" : "2021-06-04T11:57:15.590Z",
       "terms" : "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
       "title" : "Sample Contract"
    }
    

Conclusion

There are a lot of different ways you can implement an authentication system for your application and passwordless is one of those. I hope you enjoyed this article and learned how to implement Passwordless Authentication via Magic Link using the Rails API.

Thank you for reading.


References