Magic Link Authentication: How It Works and How to Build It Securely
Magic Link authentication looks simple on the surface: enter your email, receive a link, click it, and you're authenticated. But behind that simple experience are important security decisions around cryptographically secure tokens, hashing, expiration, one-time use, and token storage. In this article, I break down how Magic Link authentication works, why each step matters, and how to approach its implementation securely in a production application.
One of the things I’ve been exploring recently is production-grade authentication, and Magic Link authentication caught my attention.
At first glance, the idea seems straightforward.
Magic Link. What Is It?
A magic link is a special, one-time URL sent to a user's email that allows them to authenticate without typing a password.
It is called a magic link 😂 because the user doesn't have to remember or type a password. There is no magic involved at all. The email inbox becomes the proof that the user controls that email address.
Because of the issues that passwords create, such as weak passwords, phishing, forgotten passwords, etc., and developers have to securely handle password hashing, password changes, password resets, the idea of magic links comes into the picture.
Magic links remove the need for the user to create and remember a password.
How Does a Magic Link Work?
The idea is quite simple.
A user enters a password. The server generates a strong, cryptographically secure random value.
To ensure it is strong, we use crypto.randomByte(32).
Why 32 bytes?
Because:
1 byte = 8 bits
32 bytes = 256 bits
So, we generate a 256-bit random token, which is practically impossible to guess through brute force.
The server then hashes this token and stores it.
It then generates a unique link with the token and sends it to the email the user supplied.
When a user clicks on the link, the server extracts the token, hashes it, and compares it with the hashed token it stored earlier.
The server actually checks several things:
- Does this token exist?
- Does this token correspond with the stored token?
- Has this token expired?
- Has this token been used before?
Are Magic Links Secure?
Magic links eliminate many password-related risks, but they do not magically make authentication secure.
The security depends heavily on the user's email account.
For high-security applications, you may consider phishing-resistant methods such as passkeys/WebAuthn, which we will discuss in our next article.
Why SHA-256 Instead of bcrypt?
NB: You could use bcrypt to hash here.
bcrypt uses a salt when hashing, but a cryptographically random 256-bit token is already extremely difficult to guess.
bcrypt is intentionally made slower. A fast cryptographic hash such as SHA-256 is commonly appropriate for storing the token's digest.
My Take
Use bcrypt for passwords and SHA-256 for stronger hashing.
Another reason is that bcrypt generates a random salt for every hash and stores the salt as part of the hash.
So that when it wants to verify, it extracts the salt and uses it to compare passwords.
Salt
salt makes passwords produce different hashes.
Cost Factor
cost factor controls how computationally expensive bcrypt should be.
Implementing Magic Link Authentication
Let's look at how this can be implemented using Next.js, Prisma, and a magic-link verification flow.
Request Route
The first route receives the user's email and name.
// request route
import { sendEmail } from "@/lib/auth/sendEmail";
import prisma from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { email, name } = await request.json();
const regexPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !name) {
return NextResponse.json({
success: false,
message: "email is required!!"
});
}
if (!regexPattern.test(email)) {
return NextResponse.json({
success: false,
message: "Not a valid email email address"
});
}
let user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
user = await prisma.user.create({
data: {
email,
name
}
});
}
const userId = user.id;
const url = sendEmail(email, userId);
return NextResponse.json({
success: true,
message: "successfull",
email
});
} catch (error) {
console.log(error);
return NextResponse.json({
success: false,
message: "internal server error"
});
}
}
The basic flow here is:
User
↓
Enters email
↓
POST /request
↓
Find user
↓
Create user if necessary
↓
Generate/send magic link
↓
User receives email
Verify Route
After the user receives the email and clicks the magic link, the verification route handles the token.
// verify route
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { verifyMagicLinkToken } from "@/lib/auth/magic-link";
import { createSession } from "@/lib/auth/session";
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const token = searchParams.get("token");
if (!token) {
return NextResponse.redirect(
new URL("/login?error=missing_token", req.url)
);
}
const email = await verifyMagicLinkToken(token);
if (!email) {
return NextResponse.redirect(
new URL("/login?error=invalid_or_expired_token", req.url)
);
}
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
return NextResponse.redirect(
new URL("/login?error=user_not_found", req.url)
);
}
await createSession(user.id);
return NextResponse.redirect(
new URL("/dashboard", req.url)
);
} catch (error: any) {
console.error("[Magic Link Verify Error]:", error);
return NextResponse.redirect(
new URL("/login?error=verification_failed", req.url)
);
}
}
The verification flow is:
User clicks magic link
↓
Server extracts token
↓
Verify token
↓
Check token validity
↓
Check expiration
↓
Check whether token was already used
↓
Find user
↓
Create session
↓
Redirect to dashboard
Magic Link authentication may look simple from the user's perspective:
Enter email → Check email → Click link → You're logged in.
But behind that simple experience are several security decisions that the developer has to get right.
see you in the next article
Comments (0)
No comments yet. Be the first to share your thoughts.