DEV Community

Chinara James
Chinara James

Posted on • Originally published at chinarajames.com

How to create a password less sign in workflow (magic links)

If you have ever used apps like Slack you would have come across a nifty way to sign in using your email address only, password-less or email only sign in using "magic links". This is especially useful as people usually have a hard time remembering passwords or are accessing apps via phones and have a hard time typing in complicated passwords. It works by sending the user a "magic link" which they then click and are automatically authenticated and signed in.

Screencast


In the post, I'm going to show you how to roll your own simple password-less sign in for you application. I am going to build it using ExpressJs but the logic can be applied to any project.

The basic steps to a password-less sign in are as follows:

  1. Generate a token and associate it with a user
  2. Generate a url which will be a combination of the token and query parameters example email or id
  3. Email the url to the user
  4. Validate the token and authenticate the user when they use the url

Setting up a database and scaffolding the app

Before you begin, you need a simple database with at least a users table. The table should have at the minimum the following fields:

  • id
  • name
  • email
  • token
  • token_date

I'm using Airtable for the database as it provides a quick and easy way to get a simple database and they have a nice API to get the data.

Just want the code?

If you would like to follow along you can get the base code, my Express Airtable Starter or the full working demo.

Generate a token an associate it with a user

In your controllers directory, create a new controller to handle user logic. Follow a sensible naming convention example userController.js.

In this file you will require in the dependencies, of course these should be installed previously using your preferred package manager (npm or yarn). The package you will need are as follows:

// Mandatory
const querystring = require("querystring");
const nodemailer = require("nodemailer");
const diffInMinutes = require("date-fns/difference_in_minutes");

//Only if you are using Airtable as I am
const Airtable = require("airtable");

Optional: Airtable config

If you're following along with me and using Airtable as the database, you would need to store your API key and Base ID in the environment variables file (variables.env) and configure your app to use the Airtable API.

// userController.js
// After dependency declarations
const base = new Airtable({
  apiKey: process.env.AIRTABLE_API_KEY
}).base(process.env.AIRTABLE_BASE_ID);

const TABLE = base("users");
const VIEW = "Grid view";

I have also abstracted a helper function to get records from Airtable no matter the table and view. This I have store in a controllers/dataController.js file. Notice that you pass in two parameters, the name of the table and the name of the view.

// dataController.js

exports.getAirtableRecords = (table, view) => {
  let records = [];
  return new Promise((resolve, reject) => {
    // Cache results if called already
    if (records.length > 0) {
      resolve(records);
    }

    const processPage = (partialRecords, fetchNextPage) => {
      records = [...records, ...partialRecords];
      fetchNextPage();
    };

    const processRecords = err => {
      if (err) {
        reject(err);
        return;
      }

      resolve(records);
    };

    table
      .select({
        view
      })
      .eachPage(processPage, processRecords);
  });
};

In order to use our above helper function we need to require in this file in our user controller file.

// userController.js
const data = require("./dataController.js");

We will then create two helper functions. One to generate a token and the other the generate the magic link.

// userController.js
const generateToken = (id, email) => {
  let source = `${id}${email}`
  let token = ''
  for (let i = 0; i < source.length; i++) {
    token += source.charAt(Math.floor(Math.random() * source.length));
  }

  return token;
}

const generateLoginUrl = (token, email) => {
  let url = '';
  url = `login/magiclink/${token}?${querystring.stringify({email: email})}`
  return url
}

User sign up

Let's create a route and view for the user to sign up. If you're following along all my app's routes are located in routes/index.js. First let's create a route that would display the sign up page. For simplicity, I'm using the home page or index.html as the sign up page, so the route would be

// index.js
router.get("/", userController.getSignup);

If you're not familiar with routes, it simply tell the app what function(s) to execute when a request is made (example: user visits a page or submits a form) to a particular url. So here if we get a GET request to the homepage ('/') the application should execute the getSignup function in the user controller.

Now in our userController.js file we create the getSignup function.

// userController.js
exports.getSignup = (req, res) => {
  res.render('index'});
});

The above simply loads the index (homepage) template, which in my case I'm using pug so the index.pug file will be rendered.

extends ../layouts/base

block content
  h1.title Sign Up

  if(message)
    .notification= message

  form.form.form--signup(action="/signup", method="POST")
    .field
      label.label(for="name") Full Name
      .control
        input#name.input(type="text", name="name")

    .field
      label.label(for="email") Email Address
      .control
        input#email.input(type="email", name="email")

    .field
      .control
        input.button.is-primary(type="submit", value="Sign Up")

Note the form action and method. When the user submits, we are sending a POST request to the url '/signup'. Let's create the route and the functions for signing up.

The logic for the sign up flow is

  1. Creating a new user record
  2. If this is successful we use the user id and email address to generate the token and update the user record
  3. We then flash a message to the user to 4. check their email for the link
  4. Generate the magic link
  5. Email them the magic link
// index.js
router.post("/signup", userController.signUp, userController.sendEmail);

// userController.js
exports.signUp = (req, res, next) => {
  // 1. Create a new user record
  TABLE.create(
    {
      name: req.body.name,
      email: req.body.email
    },
    (err, record) => {
      if (err) {
        console.error(err);
        return;
      }

      // 2a On success, generate a token
      const token = generateToken(record.getId(), req.body.email);

      // 2b Update the user record to save the token and token creation date
      TABLE.update(
        record.getId(),
        {
          token,
          token_date: new Date().toISOString()
        },
        (err, record) => {
          if (err) {
            console.error(err);
            return;
          }

          // 3. On success, flash a message
          res.render("index", {
            message: "Please check your email for you magic sign in link"
          });

          // 4. Generate Magic Link
          req.body.url = generateLoginUrl(token, record.get("email"));

          // 5. Send Email
          next(); // This call the next function in chain declared in route, in this case sendEmail()
        }
      );
    }
  );
};

exports.sendEmail = (req, res) => {
  const to = req.body.email;
  const subject = "Magic sign in link for My Sweet App";
  const body = `Hello,
  Here is your magic link for quickly signing into My Sweet App.
  <a href="http://localhost:7777/${
    req.body.url
  }">Sign in to My Sweet App</a>
  You can also copy and paste this link in your brower url bar.
  <a href="http://localhost:7777/${req.body.url}">http://localhost:7777/${
    req.body.url
  }</a>`;

  const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: process.env.SMTP_PORT,
    // secure: true,
    auth: {
      user: process.env.SMTP_USERNAME,
      pass: process.env.SMTP_PASSWORD
    }
  });

  const mailOptions = {
    from: process.env.FROM_EMAIL,
    to,
    subject,
    html: body
  };

  transporter.sendMail(mailOptions, (err, info) => {
    if (err) {
      console.log(err);
    } else {
      // email sent
      console.log(info);
      res.send(info.messageId);
    }
  });
};

Note that I am using nodemailer to handle sending email. If you are using my starter files it's already included and package.json. I'm also using Mailtrap as an SMTP server to test that the emails are being sent. Go ahead and sign up, they have a generous free plan.

User sign in

Now that we have handled the user signing in, let's create the routes, views and logic for the using signing in using a magic link.

The steps for signing the user in are

  1. Check if the user exists
  2. Check if the token is valid, that is it belongs to the user and it has not expired
  3. Sign the user in and take them to their profile

To accomplish the above, we need 3 routes:

  • Route to display the login form, GET request to /login
  • Route to create a magic link and email it to the user if their token is expired, POST request to /login
  • Route to validate and authenticate the user using the magic link, GET request to /login/magic/ and a dynamic parameter :token
// index.js
router.get('/login', userController.getLogin);
router.post('/login',
  userController.createMagicLink,
  userController.sendEmail);
router.get('/login/magiclink/:token', userController.authenticate);

// user.Controller
exports.getLogin = (req, res) => {
  res.render('login', { title: 'Sign in to your profile' });
};

exports.createMagicLink = async (req, res, next) => {
  // Check if the user exists
  const user = await getUserByEmail(req.body.email);

  // If the user does not exist, let them know
  if (Object.entries(user).length === 0) {
    res.render('index', { message: 'User does not exist.' });
  }

  // Generate a token
  const token = generateToken(user.id, user.email);


  // Update token and token date
  TABLE.update(user.id, {
    token,
    token_date: (new Date()).toISOString(),
  }, (err, record) => {
    if (err) { console.error(err); return; }

    res.render('index', { message: 'Please check your email for your magic sign in link', user: record });

    // Generate magic link
    const url = generateLoginUrl(token, user.email);

    // Send the magic link
    req.body.url = url;
    next();
  });
};

exports.authenticate = async (req, res) => {
  const token = req.params.token;
  const email = req.query.email;
  const user = await getUserByEmail(email);

  if (Object.entries(user).length === 0) {
    res.render('index', { message: 'User does not exist.' });
  }

  if (user.email === email && user.token === token) {
    // Check if token is expired
    const isExpired = diffInMinutes(new Date(), user.date) > 5;

    if (isExpired) {
      res.render('login', { message: 'Magic link has expired. Enter your email to receive a new one.' });
    } else {
      res.render('profile', { user });
    }
  }
};

Notice we have to check if the user exists multiple time and return their data to be used in other functions and be sent to views. I have abstracted this to a helper function getUserbyEmail. We will add this function with our other helper functions at the top of the user controller file.

// userController.js
const getUserByEmail = async email => {
  let record = {};
  const users = await data.getAirtableRecords(TABLE, VIEW);
  users.filter(user => {
    if (user.get("email") === email) {
      record = {
        id: user.getId(),
        name: user.get("name"),
        email: user.get("email"),
        token: user.get("token"),
        date: user.get("token_date")
      };
      return true;
    }
    return false;
  });

  return record;
};

This function returns an object with the user details. We are using our other helper function from dataController.js which returns a promise. When resolved we will get all records from Airtable and then can filter the result for a matching email address. If there are a lot of records this could take a while and our app will be stuck, hence the use of promises.

There you have it, a simple way to roll your own password-less sign in for your app.

Top comments (0)