Add Passwordless Authentication to your React App

Kunal Shah

Kunal Shah / March 19, 2021

5 min read

It's increasingly frequent that companies are being hacked and their users' personal information and passwords are being exposed. Exposed passwords are then a cascading vulnerability, as they can be used against other sites to farm more personally-identifiable information, often culminating in access to bank account or identity theft.

Luckily, implementing passwordless authentication in your own application to prevent such situations is easier than it first seems.

The following client implementation will work on a web (React or Next.js) or mobile (react native) environment, and the server a nodeJS environment. Most database and cookie-setting operations use psuedocode.

Show me the code

Start by creating a Magic_Link table in your database with the following definition

-- Table Definition
CREATE TABLE "public"."Magic_Link" (
    "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "email" text NOT NULL,
    "forwardingUrl" text NOT NULL,
    "id" int4 NOT NULL DEFAULT nextval('"Magic_Link_id_seq"'::regclass),
    "key" text NOT NULL,
    "secret" text NOT NULL,
    "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "userId" int4,
    "fulfilled" bool NOT NULL DEFAULT false,
    CONSTRAINT "Magic_Link_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE,
    PRIMARY KEY ("id")
);

Notice the unique columns key and secret, one of which will be returned to the client that is requesting to be authenticated, and the other will be sent via email to the user and later compared on the server to the corresponding Magic_Link to determine if the original client's request should be approved.

Moreover, the createdAt column will let us determine if the link was generated within a designated expiration period. Here we'll give the user 10 minutes to find and fulfill the magic link.

To do this we'll create a backend route, which here we'll call /api/auth/get-magic-link, and retrieve or create a user corresponding to the given email.

const {
  body: { email }
} = req;

// find or create the intended user
let targetUser = await findOrCreateUser({ email });

Then, using the uuid package, create two UUIDs for the magic link key and secret

import { v4 } from 'uuid';

// within the handler
const key = v4();
const secret = v4();
const forwardingUrl = `${origin}/api/auth/fulfill/${secret}`;

await db.magic_Link.create({
  data: {
    email,
    forwardingUrl,
    key,
    secret,
    user: { connect: { id: targetUser.id } }
  }
});

Now, use an email provider like postmark to send the magic link to the user's email inbox.

const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_KEY);
const fromEmail = 'yourapp@domain.com';

// in function
try {
  client.sendEmail({
    From: fromEmail,
    To: email,
    Subject: 'Sign into YOUR_APP',
    HtmlBody: `Hi, <a href="${forwardingUrl}">Click here</a> to log into YOUR_APP.`,
    MessageStream: 'outbound'
  });

  return res.json({ success: true, key: key });
} catch (error) {
  console.log('❌ ERROR sending email: ', error);
  return res.json({ error: error });
}

Remember to return the key to the client, but not the secret, since that would defeat the purpose.

On The Client

You'll want a controlled input element and have a user-submitted email in state. When the login form is submitted, display a loading state as you create the magic link and wait for the user to open their email.

const API_URL = window.location.origin + `/api/auth/get-magic-link`;
res = await fetch(API_URL + url, {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json'
  },
  body: data ? JSON.stringify(data) : ''
}).then((r) => r.json());

Check if a key is returned, and store it in state.

if (res && res.key) {
  setKey(res.key);
}

Now, wait

Are we there yet?

While incessantly pestering the server to inquire if we've been let in, of course. In code, this means polling to check if the key has been renited with it's secret partner.

LoginScreen.jsx
useInterval(async () => {
  if (key) {
    try {
      const poll = await fetcher(`/api/auth/fulfill/poll`, {
        key
      });
      if (poll && poll.success && poll.user) {
        await mutate('/api/me', { ...poll.user, meditations: [] });
        navigation.navigate('Feed');
        return;
      }
    } catch (error) {}
  }
}, 1500);

Where useInterval is defined as

useInterval.jsx
import React, { useState, useEffect, useRef } from 'react';

export function useInterval(callback: any, delay: number) {
  const savedCallback = useRef();

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

Back to the server

Now, we set up the polled endpoint to return a user if the magic link corresponding to the key is fulfilled.

/api/auth/fulfill/poll.ts
const { key } = req.body;

let magicLink = await db.magic_Link.findUnique({
where: { key },
});

if (magicLink && magicLink.fulfilled) {
//   Authenticate the user by setting a cookie or user sesion
return res.json({ user: magicLink.user, success: true });
}

The final piece

Now, all that's left to do is actually update the magic link in our database when the link in the authentication email is clicked.

We recall that the forwardingUrl within the email was set to /api/auth/fulfill/${secret}, so we create a dynamic route that corresponds.

NOTE: the following route syntax for [secret].tsx reflects that of Next.js Dynamic Routes.

/api/auth/fulfill/[secret].ts
const { secret } = req.query;

let magicLink = await prisma.magic_Link.findUnique({
    where: { secret },
});
if (magicLink) {
    magicLink = await prisma.magic_Link.update({
        where: { secret },
        data: { fulfilled: true },
    });

return res.redirect("/success");
} else {
return res.status(401).send();
}

Boom

Passwords are a thing of a past.