How to Implement a Magic Link Authentication using Node.js and JSON Web Token (JWT)
Common passwordless authentication method - Edition #17
Hey, I'm Marco and welcome to my newsletter!
As a software engineer, I created this newsletter to share my first-hand knowledge of the development world. Each topic we will explore will provide valuable insights, with the goal of inspiring and helping all of you on your journey.
in this episode I will explain how the magic link authentication mechanism works by showing you an example of the implementation.
You can download all the code shown directly from my Github repository: https://github.com/marcomoauro/magic-link-auth
👋 Introduction
Passwordless login is a modern authentication method that allows users to access their accounts without entering a traditional password.
Instead of relying on passwords, users receive a secure authentication link or code via email, SMS, or other communication channels. This approach enhances security by reducing the risks associated with password-based authentication, such as password theft or phishing attacks.
This approach provides a seamless and user-friendly authentication experience while maintaining a high level of security
Substack login
Substack also uses this approach for its users' logins.
If the login is successful, a session token is generated. It is configured as a Set-Cookie, so each time the client contacts the server, the token is automatically sent, ensuring correct identification. This approach is stateful, meaning that a state is managed on the database side.
How do I know this information?
I made a system that allows email sequences to be sent in Substack, to make it I used the internals calls that the client makes to the backend, to make them work I reverse engineered the platform to understand how the communication happens. I documented everything in this episode:
JSON Web Tokens simplify this mechanism. Firstly, the protocol is stateless, which means the token contains all necessary information for verification such as issuer, expiration, and authenticity. It also allows for a payload (claim) to store useful information like user ID or email. This payload mustn't contain sensitive information since the claim is often sent with only Base64 encoding; for sensitive data, encryption is recommended.
Are you curious about how JSON Web Tokens work and the encryption methods that can be used? Sign up, I'll share a post with you in the upcoming weeks!
❓ How it works
We can use this sequence diagram to explain how magic link authentication works:
User starts the registration process by entering their email address
Client calls the server's registration API by sending the email
The server creates a “sign in” token by including the email in the JWT token claim
The server uses the email service to send the user an email with a link to a client page. This link includes the JWT “sign in“ token
The email sending service confirms the sending of the email to the user
The server API replies to the client
The client displays a message to the user, informing them that they need to check their email to proceed with authentication to the system
The user clicks on the link received via email and is directed to a client page
The client retrieves the email from the URL and sends it to another backend API for authentication
The server verifies the 'sign-in' token's authenticity by ensuring that it has not expired
The user's email is extracted from the claim of the “sign-in” token
The server queries the database to check if the user has registered before
The database replies to the server with the search results
If the user is not already registered, a new record is created in the database using this email
The database replies to the server with the insert results
An “authentication” token is created to authenticate client requests to the server. This token includes the user's ID in the claim
The server API responds to the client with the "authentication" token
The user accesses the platform
“sign in“ vs “authentication“ tokens
There are two JWT tokens generated:
“sign in" token: it is used for sending the email and is checked when the user clicks the link in the email during registration API invocation. This token has a short expiration time, requiring a new email to restart the process if it expires.
“authentication" token: this token is used by the client to access all server services. Upon each call, the server verifies the token's validity and attributes the calls to the user based on the ID in its claim.
These tokens are generated using different secrets, this prevents using one token instead of the other and vice versa.
A symmetrical algorithm is used for the generation, as the server is responsible for creating and verifying authenticity.
When the "authentication" token expires, the entire process must be repeated. Alternatively, a "refresh" token can be generated alongside the "authentication" token in the same API call. This “refresh" token can be used to update the "authentication" token, ensuring continuous access.
👨💻 Let’s get down to practice
You can download all the code shown directly from my Github repository: https://github.com/marcomoauro/magic-link-auth
We will create two APIs:
POST /send-magic-link: This API takes the user's email as input and generates a JWT token ("sign in") with an expiry date of one hour. This token is then inserted into a link and sent by email to the user.
POST /login-by-magic-link: This API retrieves the "sign in" token and creates a new user in the database if one does not exist. It then generates the "authentication" token required for client-server communication.
POST /send-magic-link
We use the jwt.js module for generating the two tokens:
import jwt from 'jsonwebtoken';
import {APIError401} from "../errors.js";
export const createTokenForMagicLink = (payload) => jwt.sign(payload, process.env.JWT_AUTH_SECRET_MAGIC_LINK, {expiresIn: '1h'});
export const createAuthToken = (payload) => jwt.sign(payload, process.env.JWT_AUTH_SECRET);
export const decodeToken = (token, secret) => {
try {
jwt.verify(token, secret); // check if token is expired
return jwt.decode(token); // decode payload
} catch (error) {
throw new APIError401();
}
};
As you can see, tokens are generated using two secrets stored in separate environment variables.
We use the mailer.js module, which was introduced in the previous episode. You can find it here:
We create the auth.js controller, which manages the API invocation:
export const sendMagicLink = async ({email}) => {
email &&= email.toLowerCase()
log.info('Controller::auth::sendMagicLink', {email})
if (!email) throw new APIError400('email is required')
const magic_token = createTokenForMagicLink({email})
const subject = '🪄 Magic link - confirm your email 🔗'
const body = '🔐 Click on the link below to login:\n\n' + '🔗 ' + 'https://magic-link-auth.bubbleapps.io/version-test?magic_token=' + magic_token
await sendMail({email, subject, body})
return {success: true}
}
It takes the user's email as input, generates the 'sign in' token, and sends it by email to the user.
We create the application's router, which includes all the routes that the server responds to, in the file router.js:
import {routeToFunction} from "./middlewares.js";
import {sendMagicLink} from "./controllers/auth.js";
router.post('/send-magic-link', routeToFunction(sendMagicLink));
export default router;
POST /login-by-magic-link
We start by creating the User.js model, which manages interactions with the database related to users:
import log from '../log.js'
import {APIError404} from "../errors.js";
import {query} from '../database.js'
export default class User {
id
email
constructor(properties) {
Object.keys(this)
.filter((k) => typeof this[k] !== 'function')
.map((k) => (this[k] = properties[k]))
}
static fromDBRow = (row) => {
const user = new User({
id: row.id,
email: row.email,
})
return user
}
static getOrCreate = async ({email}) => {
log.info('Model::User::getOrCreate', {
email,
})
let user
try {
user = await User.get({email})
if (user) return user
} catch (error) {
if (!(error instanceof APIError404)) throw error
}
user = await User.create({email})
return user
}
static get = async ({id, email}) => {
log.info('Model::User::get', {id, email})
const params = []
let query_sql = `
select *
from users
where true
`;
if (id) {
query_sql += ` and id = ?`;
params.push(id);
}
if (email) {
query_sql += ` and email = ?`;
params.push(email);
}
const rows = await query(query_sql, params);
if (rows.length !== 1) throw new APIError404('User not found.')
const user = User.fromDBRow(rows[0])
return user
}
static create = async ({email}) => {
log.info('Model::User::create', {email})
const {insertId: id} = await query(`
insert into users (email)
values (?)`,
[email]
);
const user = await User.get({id})
return user
}
}
we will use the getOrCreate method to create the user if it does not exist.
We define a new method in the auth.js controller that will handle the API call:
export const loginByMagicLink = async ({magic_token}) => {
log.info('Controller::auth::loginByMagicLink', {magic_token})
if (!magic_token) throw new APIError400('magic_token is required')
const {email} = decodeToken(magic_token, process.env.JWT_AUTH_SECRET_MAGIC_LINK)
const user = await User.getOrCreate({email})
const auth_token = createAuthToken({user_id: user.id})
return {auth_token}
}
The decodeToken function verifies the authenticity and expiration of the token and returns the claim. If the user does not already exist with that email, a new user record is created. Lastly, we generate the application token for use in client-server communication, including the user ID in the claim.
Finally, we add a new route in the router.js file:
import {routeToFunction} from "./middlewares.js";
import {sendMagicLink,
loginByMagicLink} from "./controllers/auth.js";
router.post('/send-magic-link', routeToFunction(sendMagicLink));
router.post('/login-by-magic-link', routeToFunction(loginByMagicLink));
export default router;
⭐️ Bonus: Try my application with UI!
I developed a UI through Bubble, a low-code tool for making graphical user interfaces.
You can use it to see how the login mechanism works: https://magic-link-auth.bubbleapps.io/version-test/
🌟 Top lectures of the week
Why Software Engineering Is Still The Best Career Option In 2024
My Compensation Growth Over Years
How to Onboard New Team Members
Simplifying as much as possible is the way to go in the engineering industry
And that’s it for today! If you are finding this newsletter valuable, consider doing any of these:
🍻 Read with your friends — Implementing lives thanks to word of mouth. Share the article with someone who would like it.
📣 Provide your feedback — We welcome your thoughts! Please share your opinions or suggestions for improving the newsletter, your input helps us adapt the content to your tastes.
💬 Chat with me — If you have any doubts or curiosity, please write to me, I will be happy to answer you!
I wish you a great day! ☀️
Marco
Great post, nice breakdown and explanation!
But what happens when someone can eavesdrop on the signed token link? Let's say your email server is not great or doesn't encrypt and you're on starbucks network.
Great article and thanks for the mention Marco!