Dec 13, 2023
Nick Parsons
In this post, we discuss the benefits of magic links, how they work, and why you should use them for passwordless authentication.
Data breaches and password overload have made companies and their users wary of using a traditional username/password authentication system. Companies know that handling user passwords is both technically challenging and costly, as it requires stringent security measures, robust infrastructure for storage, and continuous monitoring to prevent unauthorized access. Users find it cumbersome to manage multiple complex passwords for various accounts, especially with the prevalence of methods like SSO, and often end up reusing passwords across different platforms, as a matter of convenience; this not only leaves the individual vulnerable, but also makes companies jobs of securing data that much harder.
Luckily, magic links have emerged as an elegant solution to secure user sessions in a passwordless context. Their rising popularity is underpinned by their dual advantages:
Magic links are a token-based authentication (TBA) strategy that uses a unique, time-sensitive URL, which leverages a securely-generated token to serve as a credential for user authentication.
The links are sent directly to the user's registered email or phone number, providing a straightforward, secure authentication method. When a user clicks on a magic link, the embedded token is validated against the server to authenticate the user's identity. This process, by design, eliminates the traditional risks associated with password-based systems and simplifies the login experience for the user.
In this guide, we will show you why you should consider magic links and how they work at a high level, before going through a Next.js App Router implementation to show how you can add magic links to your application.
Magic links bolster the security architecture of authentication systems by adopting a token-based, stateless interaction model. Each link is cryptographically unique and typically accompanied by an expiration timestamp, making it resilient against replay attacks. Given their ephemeral nature, even if a magic link were to be intercepted or exposed, its short-lived validity constrains the window of opportunity for malicious exploitation.
But there are a few other benefits to magic links for companies and users:
A magic link is structurally composed of two critical elements: the URL, which provides the link for the user's web interaction, and the embedded token, a cryptographically-generated string serving as the temporary credential.
In essence, a typical magic link may resemble the following structure:
https://example.com/authenticate?token=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Here, the token
query parameter carries the weight of authentication, substantiating the user's claim without revealing identity until verified by the server.
Here’s an example of how the process works:
Magic links are designed to become invalid under certain conditions for security purposes:
The lifecycle of a magic link commences with the generation of a unique token. This process employs cryptographic algorithms to ensure each token is a random, high-entropy string, making it virtually impossible to predict or reproduce through brute force or other cryptographic attacks. Typically, the token generation utilizes HMAC or AES combined with a CSPRNG to guarantee the robustness of the token against collision and preimage attacks.
Once generated, the token is stored on the server along with metadata that includes the user's identifier, the token's expiration time, and any other relevant session data. The magic link is then composed by appending the token to a predetermined URL structure, forming a complete, ready-to-use hyperlink.
This link is dispatched to the user's email address or phone number via SMTP or SMS protocols. The communication channel must be secure, leveraging TLS for email and similarly secure protocols for SMS to safeguard the link during transit.
pically interacts with the magic link by clicking on it, which initiates a secure request to the service's endpoint. The service extracts the token from the URL and verifies it against the stored data. This verification process involves several checks:
The server considers the authentication request legitimate if the token passes these checks.
Post-verification, the server establishes a session for the user. This session is typically stateless, with a new session token or cookie generated to maintain the user's authenticated state in the application. This session token is separate from the magic link token. It has its own security considerations, such as being HttpOnly and Secure, to prevent access via client-side scripts and ensure transmission over HTTPS only.
Let’s create our own magic links. We’ll use Next.js 13 with the App Router. We’ll also use Supabase for our backend database to store users and tokens.
First, let’s create a new Next.js project:
npx create-next-app@latest
Follow the prompts to select how you want to configure your app, but be sure to select “Yes” for “Would you like to use App Router? (recommended)”.
Once you have created and configured your project, cd
into the created directory and run npm run dev
to start it. You’ll see just the default Next.js homepage when you load localhost:3000
.
Before we start building out our project, we need to install a few dependencies that we’ll use. Install them using:
npm install nodemailer jsonwebtoken @supabase/supabase-js
What do these do?
With those installed, let’s open up the project in an IDE. In total, we’re going to have two pages, two API routes, and two helper libraries:
page.js
will be our homepage. It will have a simple email field, and will call our requestMagicLink
API reroute.requestMagicLink.js
will be the API route that will create our magic link, send it to the email address passed from page.js
, and save the token on our Supabase database.verify.js
will be the API route called when the user clicks on the magic link in their email. It will verify the token and redirect the user to the protected dashboard page.dashboard/page.js
will be a simple mock “protected page” (that would require the user to be logged in to view it).lib/database.js
will be a number of database helper functions to save, load, and delete Supabase data.lib/supabaseClient.js
will set up our Supabase client.Let’s start with what the user will see, page.js:
// app/page.js"use client";import { useState } from "react";export default function RequestMagicLink() {const [email, setEmail] = useState("");const [message, setMessage] = useState("");const [isLoading, setIsLoading] = useState(false);const handleSubmit = async (event) => {event.preventDefault();setIsLoading(true);setMessage("");try {const response = await fetch("/api/auth/requestMagicLink", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({ email }),});const data = await response.json();if (response.ok) {setMessage("Magic link sent! Check your email to log in.");} else {setMessage(data.error || "An error occurred. Please try again.");}} catch (error) {setMessage("An error occurred. Please try again.");} finally {setIsLoading(false);}};return (<form onSubmit={handleSubmit}><inputtype="email"value={email}onChange={(e) => setEmail(e.target.value)}placeholder="Enter your email"required/><button type="submit" disabled={isLoading}>{isLoading ? "Sending..." : "Send Magic Link"}</button>{message && <p>{message}</p>}</form>);}
The main part of this code is the RequestMagicLink
component, which is responsible for handling the functionality of requesting a magic link via an email address. This component provides a UI for users to request a magic link. Users enter their email and submit the form, triggering a request to the server. Feedback is given to the user through messages and button state changes during the process.
First, we set up the state variables for the page. In the Next.js App Router, you can only use useState
in client-side components. As all components default to server-side, we have to use the “use client”
directive at the top of the file to show this page has to be rendered on the client. We have three state variables:
email
: Stores the user's email address. It's updated every time the user types into the email input field.message
: Used to display messages to the user, like confirmation or error messages.isLoading
: Indicates whether the request is being processed. It's used to disable the submit button and change the button text while the request is in progress.After that, we have the handleSubmit
function. This is triggered when the form is submitted, and initially prevents the default form submission action with event.preventDefault()
. It then sets isLoading
to true
to indicate the start of an asynchronous operation and clears any previous messages stored in message
.
Then, comes the core part of the component. We make an async POST
request to the /api/auth/requestMagicLink
endpoint with the user's email in the request body. We then update the message
state based on the success or failure of the request:
response.ok
is true), it sets a success message.We have some basic error catching, and then set isLoading
to false
.
The actual form presented to the user is basic, with just an email input field that updates the email
state variable on change and a submit button that is disabled and changes its text based on the isLoading
state. We have an onSubmit
event handler linked to handleSubmit
to send the form details and a paragraph that displays any messages stored in the message
state.
This page.js
file calls requestMagicLink.js
; let’s dig into that, next.
// app/api/auth/requestMagicLink.jsimport jwt from "jsonwebtoken";import nodemailer from "nodemailer";import { headers } from "next/headers";import { saveToken, getUserByEmail } from "../../../../lib/database";export async function POST(req, res) {const { email } = await req.json();const user = await getUserByEmail(email);if (!user) {return Response.json({ message: "User note found" });}// Create a magic link tokenconst token = jwt.sign({ email }, process.env.JWT_SECRET, {expiresIn: "1h",});const headersList = headers();const host = headersList.get("host");const magicLink = `http://${host}/api/auth/verify?token=${token}`;// Save token in your database with an expiration timeawait saveToken(user.id, token);// Set up email transporter and send the magic linkconst transporter = nodemailer.createTransport({host: <email-host>,port: 587,secure: false, // upgrade later with STARTTLSauth: {user: <email-username>,pass: <email-password>,},});await transporter.sendMail({from: <from-address>,to: email,subject: "Your Magic Link",text: `Click here to log in: ${magicLink}`,});return Response.json({ message: "Magic link sent!" });}
This code handles the API requests related to generating and sending the magic link for user authentication. Let's go through the major components and functions of the code.
The function takes req
(request) and res
(response) objects as parameters, and then extracts the email
from the request's JSON body. We then use getUserByEmail
from lib/database.js
to search for a user in the database using the provided email. If the user doesn’t exist we send a JSON response indicating the user was not found.
The function then creates the token using jsonwebtoken
to create a JWT with the user's email, signing it with a secret from environment variables and setting an expiration of 1 hour. With that token we can create our magic link. We retrieve the host from the request headers and construct a URL with the generated token as a query parameter.
The generated token is then saved in the database with an associated user ID using saveToken
.
After that, we configure a nodemailer
transporter with SMTP settings (host, port, security, and authentication credentials) and send an email to the user with the magic link.
Finally, we send back a JSON response indicating that the magic link was sent.
Here, we using one of our helper libraries, database.js
. Let’s go through that next.
// lib/database.jsimport { supabase } from "./supabaseClient";export const saveToken = async (userId, token) => {const { data, error } = await supabase.from("magic_tokens").insert([{user_id: userId,token: token,expires_at: new Date(Date.now() + 3600000),}, // expires in 1 hour]);if (error) throw new Error(error.message);return data;};export const getUserByEmail = async (email) => {const { data, error } = await supabase.from("users").select("*").eq("email", email).single();console.log(data);if (error) throw new Error(error.message);return data;};export const getTokenData = async (token) => {const { data, error } = await supabase.from("magic_tokens").select("*").eq("token", token).single();if (error) throw new Error(error.message);if (new Date(data.expires_at) < new Date()) {throw new Error("Token expired");}return data;};export const deleteUserToken = async (token) => {const { data, error } = await supabase.from("magic_tokens").delete().match({ token: token });if (error) throw new Error(error.message);return data;};
This is a collection of utility functions designed to interact with Supabase. Each function is designed to interact with specific tables in a Supabase database, which each handle different aspects like token generation, user lookup, token validation, and cleanup. Let's break down each function:
userId
(the user's ID) and token
(the magic link token).magic_tokens
table in Supabase with the user's ID, the token, and an expiration time (set to 1 hour ahead of the current time).email
, which is the email address of the user.users
table in Supabase for a record matching the provided email address.token
, the magic link token.magic_tokens
table for a record with a token matching the provided one.expires_at
field with the current time. If the token is expired, it throws an error.token
, the magic link token to be deleted.magic_tokens
table that matches the provided token.This file, in turn, is calling on the other helper library supabaseClient.js
.
// lib/supabaseClient.jsimport { createClient } from "@supabase/supabase-js";const supabaseUrl = process.env.SUPABASE_URL;const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;export const supabase = createClient(supabaseUrl, supabaseAnonKey);
This is a straightforward setup for initializing a client instance of Supabase in a Javascript application.
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
creates and exports an instance of the Supabase client, which is used to interact with your Supabase project. The createClient
function takes the Supabase URL and the anonymous key as arguments and returns the initialized client.
SUPABASE_URL
and SUPABASE_ANON_KEY
(along with JWT_SECRET
) are pulled from our environment variables file, .env.local
.
SUPABASE_URL=<supabase-url>SUPABASE_ANON_KEY=<supabase-anon-key>JWT_SECRET=<jwt-secret>
You get SUPABASE_URL
and SUPABASE_ANON_KEY
from your Supabase dashboard.
You’ll see from above, we also need to set up two tables in Supabase. We need a users
table with an email
field, and a magic_tokens
table with these fields:
user_id
, which comes from the users table.token
, which is the generated token.expires_at
, to add an expiry time to the token.Let’s get back to the main code. If we fill out the email field on the homepage and click “Send Magic Link,” requestMagicLink
will be called and an email sent to the entered email address. Clicking on that link calls the verify.js endpoint.
// app/api/auth/verify.jsimport jwt from "jsonwebtoken";import { getTokenData, deleteUserToken } from "../../../../lib/database";export async function GET(req) {const token = req.url.split("=")[1];console.log(token); // Logs the token valueconst tokenData = await getTokenData(token);if (!tokenData) {return Response.json({ error: "Invalid or expired token" });}const { email } = jwt.verify(token, process.env.JWT_SECRET);// Delete or invalidate the tokenawait deleteUserToken(token);return Response.redirect("/dashboard"); // Or wherever you want to redirect the user after login}
This defines the API route for verifying the magic link token as part of an authentication process.
The code is designed to:
The code retrieves the token from the URL query string by splitting the URL at the =
character and taking the second part (req.url.split("=")[1]
).
The getTokenData
function is called to fetch the token data from the database. If no data is found (implying the token is invalid or expired), it returns a JSON response with an error message. Using jwt.verify
, we validate the token against the secret key stored in process.env.JWT_SECRET
. This also extracts the payload (email
) from the token.
We then call deleteUserToken
to remove the token from the database, ensuring it cannot be reused. Finally, it redirects the user to the /dashboard
route upon successful token verification.
There isn’t much to dashboard/page.js
:
// app/dashboard/page.js"use client";export default function Dashboard() {return <h1>A verified page</h1>;}
In a production application, this is the page you would build out in your application.
There is a lot to think about with magic links. The above example doesn’t go into robust authentication checking with the user, nor does it add rate-limiting or have significant error handling. An unfortunate truth of authentication is that there is no silver bullet. While magic links take away the headache of managing user credentials, you still have to manage token generation, storage and expiry. Plus, you are still managing and storing user data.
Any good developer is going to take the time to understand, at least, the basic strategies leveraged by the tools they use to speed up their processes (good work understanding magic links!). With that being said, a simpler and more secure alternative to all the code above is to use Clerk. We built Clerk to make it quick and easy to add advanced authentication techniques into your application. To learn more about magic links, visit our magic link documentation or this magic link implementation article on implementing magic links with Next.js and Clerk.
Start completely free for up to 10,000 monthly active users and up to 100 monthly active orgs. No credit card required.
Learn more about our transparent per-user costs to estimate how much your company could save by implementing Clerk.
The latest news and updates from Clerk, sent to your inbox.