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:
- Creating the magic link
- 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:
- The user enters their email to log in.
- Magic Link is sent to the user.
- The user clicks Magic Link in the email.
- The API exchanges the token in the Magic Link for the access token.