Password-less Authentication Using Magic Links

Passwords have been, for better or for worse, a staple in our digital life since the advent of the Internet. Authenticating ourselves on the Internet has required passwords, but are there other ways we can prove we have access to an account.

In this article, after I rant a bit against passwords, we will look at implementing magic links, a password-less authentication method.

Password-based authentications are flawed in practice

In theory, passwords provide a high level of security, as only the rightful owner should know it. In practice though, passwords are inherently insecure. As much as 80% of data breaches are due to poor password hygiene, meaning people reusing passwords, or using simple-to-guess passwords. That in turn is due to password fatigue given the staggering number of tools and services we now use on-line.

An existing solution is to use password managers to generate strong unique passwords and store them safely. Again, this works great in practice, until your password manager gets hacked, which unfortunately happens more often than it should. Luckily some services provide multi-factor authentication (MFA), which requires another means of authentication on top of your password. That is usually a one-time password (OTP) sent via SMS, a unique code from an app using WebAuthn, or a notification on your registered phone. At this point, it's fair to wonder what the primary password is used for.

Another aspect to the discussion is also the non-negligible number of users who end up using the reset password flow to authenticate. That flow pretty much resembles password-less authentication using magic links, which is what we are looking at implementing here.  Finally, implementing a password-less authentication method means that passwords need not be handled and stored by your service, limiting the risk of mishandling and leaks, because, let's be honest, storing passwords properly isn't so straightforward.

Password-less authentication is a method to authenticate a user without the use of a password. This includes a lot of different techniques such as:

  • One-Time Passwords
  • WebAuthn
  • Possession of a unique device (phone, hardware key)
  • Possession of a unique bio-metric trait (face, fingerprint)
  • Magic links

Each technique has their pros and cons, but in this article we will focus on magic links. This technique involves sending a link by email or SMS to a user trying to authenticate to our service. The link is unique, and when clicked, authenticates the user in their browser. In a way, it is a similar flow to the reset password flow, albeit without passwords.

Some of the pros of using magic links include:

  • no passwords for the user to manager or for you to store;
  • more secure than passwords in practice;
  • simple process which only requires the user to have valid email address.

Some of the cons of using magic links include:

  • the authentication method is as secure as the user's email box, but that is already the case for reset password flows;
  • it requires users to open their email clients to login to your service, which adds friction;
  • they don't play well with password managers.

A disclaimer first: I am not a security expert, so I might miss some important security risks regarding magic links. The following is only to the best of my knowledge on the subject.

The most obvious security risk is if someone other than the user can guess the authentication link, in which case the attacker can authenticate as the user.

There are a few strategies that we can use to fend those attacks off:

Generate cryptographic random tokens with enough entropy, which will make it almost impossible to guess.

Recommendations on the length of the tokens will vary, but the benefit of magic links is that users don't need to type the token in as they would for MFA using an OTP for example. That means that we can make those tokens at least 32-bit long, or even 64-bit long without impacting user experience.

When generating the token, use a cryptographic strong random generator. In JavaScript land for example, don't use Math.random(), but instead the crypto library in Node, or bcrypt from npm.

Add a validity time limit on the magic links

The previous point should already make our links safe, but by time-bounding our magic links, we dramatically reduce the window of opportunity for an attack to be successful at guessing the link. This advice is similar to password reset flows. As a rule of thumb, a magic link should be valid for maximum 5 to 15 minutes.

Replay attacks

In replay attacks, an attacker is able to capture and reuse a link that was already used by a legitimate user. As the token appears in clear text in the link (either as a parameter or a query string), it is possible that a hostile agent can read it, and reuse it.

The simplest mitigation strategy here is to ensure that our magic links can only be used once, which would render replay attacks void.

Man-In-The-Middle (MITM) attacks

At the end of the day, the security of magic link authentication resides in the security of the user's email inbox, and the belief that the link arrives into the ends of the user who requested it. The security of a user's email account is, of course, out of scope, but we can fend off man-in-the-middle (MITM) attacks.

As the link and token are sent in plain format, it is not impossible for an attacker to intercept the message and try to authenticate with said link. To protect against that threat, we can fingerprint the browser from which the user requested the magic link. A simple strategy would be to attach a cookie, or save a token in the user's browser, and send that value back when they click on the magic link. Only the user who requested the link can therefore successfully authenticate.

If the user's email account is compromised, there is unfortunately little we can do, but the same is true with classic password workflows, and in particular password reset flows.

Now that we have looked at magic links, how they work, and what are the main security threats and mitigations, let's write an implementation of magic links.

For this example, we will be using JavaScript, Node and Prisma (an ORM for PostgreSQL, MySQL, and MongoDB).

To implement magic links, we need a few things:

  • Generate a link with a random token
  • Validate the link and token to authenticate the user

Scaffolding

To follow this mini-tutorial, you need to have Node installed on your computer. The latest version the better!

We start with a basic express app:

mkdir node-magic-link
cd node-magic-link
npm init -y
npm install express

We then create an index.js file inside our project. For now, let's just write a very basic express app:
index.js

const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.send("Hello, world!");
});

app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

We can run this app from the command line using:

node index.js

We should see in the console: Listening on port 3000. If we open http://localhost:3000 in our browser, we should see the text "Hello, world!".

Alright, so let's dive into it!

Data Model

To support our magic link password-less authentication, we will build a bare bones data model using SQLite and Prisma. The benefit of SQLite is that it is basically just a file on your computer, so there is no need to set something more complex like a PostgreSQL or Mongo database locally.

Using Prisma allows us to abstract away the underlying database, as the same code can be used for SQLite, PostgreSQL and MySQL, and with minimal changes with MongoDB. Prisma also has other advantages, so do check it out!

To get started with Prisma, run the following in your project folder:

npm i -D prisma
npm i @prisma/client

To initialize a new Prisma project:

npx prisma init --datasource-provider sqlite

This will generate a file shema.prisma in a new ./prisma folder:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

Note that you can later change the datasource provider in ./primsa/schema.prisma.

In our exercise, we only need a User model and a MagicLink model. For simplicity, our models look like follows:
./prisma/schema.prisma

model User {
  id         String      @id @default(uuid())
  name       String
  email      String      @unique
  magicLinks MagicLink[]
}

model MagicLink {
  id         String   @id @default(uuid())
  token      String
  userId     String
  user       User     @relation(fields: [userId], references: [id])
  validUntil DateTime
}

From this model definition, Prisma generates the following migration after running npx prisma migrate dev:
./prisma/migrations/**/migration.sql

-- CreateTable
CREATE TABLE "User" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "name" TEXT NOT NULL,
    "email" TEXT NOT NULL
);

-- CreateTable
CREATE TABLE "MagicLink" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "token" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "validUntil" DATETIME NOT NULL,
	"isUsed" BOOLEAN NOT NULL DEFAULT false,
    CONSTRAINT "MagicLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

We have a User table with an id as a primary key, a name as a string, and an email as a string with a "unique" constraint. We also have a MagicLink table with an id as primary key, a token as a string, a validUntil as a date, an isUsed value as a boolean, and a userId as a foreign key referencing the User table.

Let's now look at the link generation!

For simplicity, we will return a bare bones form to the user in the root router:
index.js

app.get("/", (req, res) => {
  res.send(`
    <html lang="en">
    <body>
      <form method="POST" action="/auth/link">
        <p>Enter your email to login</p>
        <label>Email: <input type="email" name="email" required/></label>
        <button type="submit">Go</button>
      </form>
    </body>
    </html>
  `);
});

To handle the form submission, we need to install body-parser and register it as follows:

npm i body-parser

`index.js

const express = require("express");
const bodyParser = require("body-parser");

const app = express();
const port = process.env.PORT || 3003;

app.use(bodyParser.urlencoded());

...

We also need to register the route the form is submitting to:
index.js

...

const { PrismaClient } = require("@prisma/client");
const db = new PrismaClient();

...
app.post("/auth/link", async (req, res) => {
  // 1. Retrieve the value of the email from the request object
  const email = req.body.email;

  // 2. Find the corresponding user
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return res.sendStatus(404); // User not found!
  }

  // 3. Generate a random token and a corresponding link
  const token = crypto.randomBytes(64).toString("hex");
  const link = `${
    req.protocol + "://" + req.get("host")
  }/auth/login?token=${token}`;
	
  // 4. Don't forget to attach a validity limit!
  const validUntil = new Date(Date.now() + 15 * 60 * 1000); // in 15 minutes

  // 5. Save the token in the database
  await db.magicLink.create({
    data: {
      userId: user.id,
      token,
      validUntil,
    },
  });

  // 6. Send the link by email
  sendEmail(email, link);

  // 7. We're done here!
  res.redirect(`/auth/link/sent?email=${email}`);
});

Here is an example of link generated by this code:
http://localhost:3000/auth/login?token=7d8e18a2ec1a7ace4b623d8eacb392b1748807048aa56a5188b4b7a418e9d88d145bb2ee098df1c1b8ce87c15a42949f0b1bc8761991751305e1dcb19ce78c61

For the following code to work properly, we need to create at least one user in our database. This can be done directly via Prisma Studio, which you can open in your browser with the following command:

npx prisma studio

Here, you can navigate to the User table and add a new row with some dummy data.

We also need a dummy sendEmail() function and a handler for the route /auth/link/sent:

function sendEmail(to, body) {
  console.log(to, body);
}

app.get("/auth/link/sent", (req, res) => {
  const email = req.query.email;

  res.send(`
  <html lang="en">
  <body>
    <p>Link sent to <strong>${email}</strong>.</p>
  </body>
  </html>
  `);
});

If we have a look at the link we created to authenticate our users, when visiting that link they will make a GET request to /auth/login, so we need to handle that as follows:

app.get("/auth/login", async (req, res) => {
  // 1. Retrieve the token from the query string of the request
  const token = req.query.token;
  if (!token) {
    return res.sendStatus(400);
  }

  // 2. Validate token
  const magicLink = await db.magicLink.findFirst({
    where: { token, isUsed: false,  validUntil: { gte: new Date() } },
  });
  if (!magicLink) {
    return res.sendStatus(404);
  }

  // 3. Mark the link as used, to avoid replay attacks
  await db.magicLink.update({
    data: { isUsed: true },
    where: { id: magicLink.id },
  });

  // 4. Create a user session and redirect the user
  // TODO: this will depend on your exact app setup ...
  const user = await db.user.findUnique({ where: { id: magicLink.userId } });
  
  res.send({ user });
});

Here we simply read the token from the request query string and we make sure that this token is still valid. If the token is valid, we mark it as used. In our example, we simply return the user, but in a real-world application you would then authenticate the user and redirect them appropriately.

Bonus: fingerprint user's browser

If you recall the brief security discussion around magic links, you can see that we have fend off a few attack scenarios, namely the guessable links and replay attacks. There is still a very minimal risk of MITM attacks, and a simple way to work around them is to fingerprint the browser from where the origin request has been made.

To do that, we will be generating another random token, and setting it as a cookie on the user's browser. This cookie will then be sent automatically by the browser when the user clicks on the magic link, and we can thus verify that the link was opened in the same browser it was requested.

To handle cookies with express we need to install another middleware, namely cookie-parser:

npm i cookie-parser

index.js

const express = require("express");
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");

const app = express();
const port = process.env.PORT || 3003;

app.use(bodyParser.urlencoded());
app.use(cookieParser());

...

We also need to store the cookie token in our database, so we need to add a field to our MagicLink model:
./prisma/schema.prisma

model MagicLink {
  id          String   @id @default(uuid())
  token       String
  cookieToken String
  userId      String
  user        User     @relation(fields: [userId], references: [id])
  validUntil  DateTime
  isUsed      Boolean  @default(false)
}

Finally, we need to generate that cookie token when the user requests a magic link, store it in our database, and set it on their browser:
index.js

app.post("/auth/link", async (req, res) => {
  // 1. Retrieve the value of the email from the request object
  const email = req.body.email;

  // 2. Find the corresponding user
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return res.sendStatus(404); // User not found!
  }

  // 3. Generate a random token and a corresponding link
  const token = crypto.randomBytes(64).toString("hex");
  const link = `${
    req.protocol + "://" + req.get("host")
  }/auth/login?token=${token}`;
	
  // 4. Don't forget to attach a validity limit!
  const validUntil = new Date(Date.now() + 15 * 60 * 1000); // in 15 minutes
	
  // 5. Generate a cookie token
  const cookieToken = crypto.randomBytes(64).toString("hex");

  // 6. Save the tokens in the database
  await db.magicLink.create({
    data: {
      userId: user.id,
      token,
      validUntil,
    },
  });

  // 7. Send the link by email
  sendEmail(email, link);

  // 8. Set the cookie on the user's browser
  res.cookie("node-magic-link-check", cookieToken, { httpOnly: true });

  // 9. We're done here!
  res.redirect(`/auth/link/sent?email=${email}`);
});

Note the changes made on steps 5., 6. and 8..

And we validate the presence of the cookie when validating the link before authenticating:

app.get("/auth/login", async (req, res) => {
  // 1. Retrieve the token from the query string of the request
  const token = req.query.token;
  if (!token) {
    return res.sendStatus(400);
  }

  // 2. Retrieve the cookie token from the cookies
  const cookieToken = req.cookies["node-magic-link-check"];
  if (!cookieToken) {
    return res.sendStatus(400);
  }

  // 3. Validate tokens
  const magicLink = await db.magicLink.findFirst({
    where: {
      token,
      cookieToken,
      isUsed: false,
      validUntil: { gte: new Date() },
    },
  });
  if (!magicLink) {
    return res.sendStatus(404);
  }

  // 4. Clear the cookie
  res.cookie("node-magic-link-check", "");

  // 5. Mark the link as used, to avoid replay attacks
  await db.magicLink.update({
    data: { isUsed: true },
    where: { id: magicLink.id },
  });

  // 6. Create a user session and redirect the user
  // TODO: this will depend on your exact app setup ...
  const user = await db.user.findUnique({ where: { id: magicLink.userId } });
  
  res.send({ user });
});

Here we just add some checks on step 2. and 3.. Then we clear it in step 4..

And that rounds up, our look at password-less authentication using magic links!

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.