DEV Community

loading...

Implement password-less authentication in your apps (magic sign in)

arnu515 profile image arnu515 ・8 min read

In this post, I'll show you how you can implement passwordless sign in, or "magic-link" sign in to your web app.

Tech stack

  • SvelteJS with Vite and Typescript for the frontend with:
  • NodeJS typescript for the backend with:
    • ExpressJS
    • MongoDB

If you don't know typescript, feel free to use plain javascript. Just remember, you can't use some features I can.

Create the project

Frontend

Creating a svelte + vite = svite app is pretty easy! All you have to do is enter this command into your terminal

npm init @vitejs/app
Enter fullscreen mode Exit fullscreen mode

I'll choose frontend for the project name, svelte for the framework and the Typescript variant.

Next, you can cd into your project and run

yarn # installs packages
yarn dev # starts the DEV server
Enter fullscreen mode Exit fullscreen mode

You can now access your frontend app at http://localhost:3000.

Feel free to use npm or pnpm instead.

Backend

The backend setup has more steps, however.

  • Create our project
# Create backend folder and cd into it
mkdir backend && cd backend
# Create a package.json
yarn init --yes # or npm init -y
Enter fullscreen mode Exit fullscreen mode

I removed the main field in package.json.

  • Install packages
# feel free to use npm/pnpm instead
yarn add express \
  cors \
  morgan \
  mongoose \
  jsonwebtoken \
  nodemailer \
Enter fullscreen mode Exit fullscreen mode
  • (TYPESCRIPT ONLY) Install typedefs of packages, and other dev dependencies
# feel free to use npm/pnpm instead
yarn add -D \ 
  @types/express @types/cors @types/morgan @types/mongoose @types/jsonwebtoken @types/nodemailer \
  @types/node \
  ts-node \
  typescript \
  nodemon
Enter fullscreen mode Exit fullscreen mode
  • Add scripts in package.json
"scripts": {
  "build": "tsc -p .",
  "build:watch": "tsc -p . -w",
  "start": "node dist/index.js",
  "start:watch": "nodemon dist/index.js",
  "dev": "npm-run-all -p build:watch start:watch"
}
Enter fullscreen mode Exit fullscreen mode
  • (TYPESCRIPT ONLY) Add a tsconfig.json
npx tsconfig.json
# select "node" from the options
Enter fullscreen mode Exit fullscreen mode

Add this to compilerOptions in your tsconfig:

"skipLibCheck": true
Enter fullscreen mode Exit fullscreen mode

I removed the baseUrl field in the config

  • Create folders and files
mkdir -p src/controllers src/models src/middlewares src/util
touch src/index.ts # use js for javascript
Enter fullscreen mode Exit fullscreen mode

How it works

Before we start with actual coding, let's take a look at how passwordless authentication, or "magic-link" authentication works.

  1. First, we ask the user for their email. This happens on the frontend.
  2. Next, the frontend sends the email to the backend.
  3. The backend searches the database for a user with the provided email. If a user is found, the user is logging in. Otherwise, the user is registering.
  4. The backend generates a code for the user, and stores it in the database. It sends the code to the user via email.
  5. The email contains a link to get the user authenticated. This link may/maynot need a code. Hence, the term magic link.
  6. The user enters the code, the backend checks it, and if the code is valid, the user is successfully authenticated.

Backend coding

Let's start with the backend, so we know what to do in the frontend.

Main app

Start with the main file, src/index.ts

import express from "express";
import cors from "cors";
import morgan from "morgan";
import mongoose from "mongoose";
import { createServer } from "http";

mongoose.connect(process.env.MONGODB_URL || "mongodb://localhost:27017/db", {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useCreateIndex: true,
});

const app = express();
app.use(cors());
app.use(morgan("dev"));
app.use(express.json());

const { PORT = 5000 } = process.env;
createServer(app).listen(PORT, () =>
  console.log("Server started on port " + PORT)
);

Enter fullscreen mode Exit fullscreen mode

Read environment variables from a file

Using the NPM package dotenv, we can read environment variables like MONGODB_URL from a file.

First, install the package:

yarn add -D dotenv # or use npm/pnpm
Enter fullscreen mode Exit fullscreen mode

Next, add this to the TOP (even before all imports) of src/index.ts.

if ((process.env.NODE_ENV || "development") === "development")
  require("dotenv").config(require("path").join(__dirname, "..", ".env"));
Enter fullscreen mode Exit fullscreen mode

I don't have mongodb!

If you don't have mongodb/can't install it, you can either use the Docker image, or use MongoDB Atlas

Make sure to add your MongoDB connection URI to your environment:

export MONGODB_URL=<YOUR URL>
Enter fullscreen mode Exit fullscreen mode

Or add the same thing to a file named .env

Models

Let's work on our Mongoose models. For this project, we'll have two models - the User model and the Code model

User model

// src/models/User.ts

import { Schema, model } from "mongoose";

export const UserSchema = new Schema({
  email: String,
  username: String,
  role: { type: String, default: "member" },
});

const User = model("User", UserSchema);

export default User;
Enter fullscreen mode Exit fullscreen mode

Code model

// src/models/Code.ts

import { Schema, model } from "mongoose";

export const CodeSchema = new Schema({
  code: Number,
  email: String,
  expiresAt: Number,
  // Exists only if the user is logging in.
  userId: { type: String, nullable: true },
});

const Code = model("Code", CodeSchema);

export default Code;
Enter fullscreen mode Exit fullscreen mode

Auth routes

// src/controllers/auth.ts

import { Router } from "express";

const router = Router();

router.post("/send_magic_link", (req, res) => {
  // Code to send the email
});

router.get("/token", (req, res) => {
  // Code to generate a token from the code in the email
});

router.get("/user", (req, res) => {
  // Code to fetch the user from the token
})

export default router;
Enter fullscreen mode Exit fullscreen mode

Let's register this controller:

// src/controllers/index.ts
import auth from "./auth";
import { Router as ExpressRouter } from "express";

export interface Router {
  router: ExpressRouter;
  path?: string;
}

export default [{ router: auth, path: "/api/auth" }] as Router[];

// --------------------------

// src/index.ts
// ...
import controllers from "./controllers";
// ...
app.use(express.json());
controllers.forEach((c) => app.use(c.path || "/", c.router));
Enter fullscreen mode Exit fullscreen mode

In this controller, we'll have two API routes. One to generate the code and send it by email, and the other to validate the code and return a token.

First, let's focus on the route to generate the code. We'll be working with the POST method for /send_magic_link.

  • Add some code to get the email from the request body
  const { email } = req.body;

  if (typeof email !== "string" || !email.trim())
    return res
      .status(400)
      .json({
        error: "Invalid email",
        error_description: "Please provide a valid email",
      });

  return res.status(200).json({ ok: true });
Enter fullscreen mode Exit fullscreen mode
  • Add some code to check if there's a user with that email
  const userId = (await User.findOne({email}))?.id;
Enter fullscreen mode Exit fullscreen mode

Be sure to import User and make the function async.

  • Add code to generate a random 6-digit code
  const code = Math.floor((Math.random() * 899999) + 100000);
Enter fullscreen mode Exit fullscreen mode
  • Add code to add the generated code to the database
  // Expire after 15 minutes
  const c = new Code({
    code,
    userId,
    email,
    expiresAt: Date.now() + 15 * 60 * 1000,
  });
  await c.save();
Enter fullscreen mode Exit fullscreen mode
  • If we test our code, you'll notice that we now have a new entry in our database
// codes collection

{
  _id: 608a5e125f5f267eccf58bd4,
  code: 504837,
  email: "test@email.com",
  expiresAt: 1619682057847,
  __v: 0
}
Enter fullscreen mode Exit fullscreen mode
  • Add code to send email
  const transport = nodemailer.createTransport({
    host: "smtp.mailtrap.io",
    port: 2525,
    auth: {
      user: "xxxxxxx",
      pass: "xxxxxxx",
    },
  });

  transport.verify((e) => {
    if (e) console.error(e);
  });

  const message = {
    from: "test@example.com",
    to: email,
    text: `Enter this code: ${code}`,
    html: `<p>Enter this code: <b>${code}</b></p>`,
  };

  transport.sendMail(message, (err) => {
    if (err) console.error("An error occured while sending email", err);
    else console.log("Mail sent");
  });
Enter fullscreen mode Exit fullscreen mode

I'm using MailTrap for a free mail server, but you can use any other service.

  • You should now have a working mail sender. Test the endpoint to make sure that mails do get sent.

  • If you followed all steps correctly, you should get an email with this text:

Enter this code: <SOME CODE>
Enter fullscreen mode Exit fullscreen mode

Now, we can work on generating a token from the code.

  • Import sign and verify from jsonwebtoken
import {sign, verify} from "jsonwebtoken";
Enter fullscreen mode Exit fullscreen mode
  • Add code to check the validity of the generated code
router.get("/token", async (req, res) => {
  const {code: codeFromQs} = req.query;

  if (typeof codeFromQs !== "string" || isNaN(parseInt(codeFromQs)))
    return res.status(400).json({error: "Invalid code", error_description: "Please send a valid code in the querystring"})

  const code = parseInt(codeFromQs);
  const c = await Code.findOne({code});
  if (!c)
    return res.status(400).json({error: "Invalid code", error_description: "Please send a valid code in the querystring"})

  return res.status(200).json({ok: true})
});
Enter fullscreen mode Exit fullscreen mode
  • Add code to add user to database and generate a token

  const { email, userId } = c as any;
  let user = null;
  if (userId) {
    user = await User.findById(userId).exec();
    if (!user)
      return res.status(400).json({
        error: "Invalid code",
        error_description: "Please send a valid code in the querystring",
      });
  } else {
    user = new User({ email, username: email.split("@")[0] });
    await user.save();
  }

  // Exp in 1 week
  const token = sign(
    { id: user._id.toString() },
    process.env.SECRET || "secret",
    {
      expiresIn: 604800,
    }
  );

  return res.status(200).json({ ok: true, token, user });
Enter fullscreen mode Exit fullscreen mode
  • Now you should be able to send a request to the endpoint, providing the code in the query. This will return you a token and with the user.

Finally, let's add an endpoint to get the user from the token:

router.get("/user", async (req, res) => {
  const authHeader = req.headers.authorization;
  if (
    !authHeader ||
    typeof authHeader !== "string" ||
    authHeader.split(" ")?.length !== 2 ||
    authHeader.split(" ")[0].toLowerCase() !== "bearer"
  )
    return res.status(401).json({ error: "Invalid auth header" });

  const identity = verify(
    authHeader.split(" ")[1],
    process.env.SECRET || "secret"
  ) as any;

  if (typeof identity === "string")
    return res.status(401).json({ error: "Invalid token" });

  if (typeof identity.id !== "string")
    return res.status(401).json({ error: "Invalid token" });

  const user = await User.findById(identity.id);
  if (!user) return res.status(401).json({ error: "Invalid token" });

  return res.status(200).json({ ok: true, user });
});
Enter fullscreen mode Exit fullscreen mode

This is what your final auth.ts controller should look like:

Frontend

With the backend all done and complete, we can start work on the frontend.

Let's add a CSS library to make our lives easier. In the <head> tag of index.html, add:

<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css" />
Enter fullscreen mode Exit fullscreen mode

I'll add an Auth.svelte component which will contain the auth form

<!-- src/lib/components/Auth.svelte -->

<script lang="ts">
  async function requestCode() {}
</script>

<div class="w3-border w3-border-gray w3-padding w3-rounded">
  <h2 class="w3-center">Authenticate</h2>

  <form class="w3-margin" on:submit="{requestCode}">
    <p>
      <label for="email">Email</label>
      <input type="email" id="email" class="w3-input w3-border w3-border-gray" />
    </p>
    <p>
      <button class="w3-button w3-black w3-hover-black" style="width: 100%"
        >Get magic link</button
      >
    </p>
  </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Now it's time to add some functionality to our app. I'll add a submit handler to the form which will ask our backend for the code.

  // <script> tag

  import { createEventDispatcher } from "svelte";

  const d = createEventDispatcher();

  async function requestCode() {
    const email = (document.getElementById("email") as HTMLInputElement)?.value;
    if (!email?.trim()) return;

    try {
      const res = await fetch("http://localhost:5000/api/auth/send_magic_link", {
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({ email }),
        method: "POST"
      });

      const data = await res.json();
      if (res.ok && data.ok) d("prompt-code");
      else {
        console.error(data);
        alert(data.error || res.statusText);
      }
    } catch (e) {
      console.error(e);
      alert("An unknown error occured");
    }
  }
Enter fullscreen mode Exit fullscreen mode

Here's our Auth.svelte file:

After we receive an email, we need to be able to enter the code in it. I'll create a new Code.svelte component, which will contain the following code:

Notice how these two files emit events? We need to handle these events in App.svelte.

<!-- src/App.svelte -->
<script lang="ts">
  import Auth from "./lib/components/Auth.svelte";
  import Code from "./lib/components/Code.svelte";

  let sentLink = false;
  let token = localStorage.getItem("token");
</script>

<h1 class="w3-center">Welcome</h1>
{#if !token}
  <div class="w3-container">
    {#if !sentLink}
      <Auth on:prompt-code="{() => (sentLink = true)}" />
    {:else}
      <Code
        on:authenticated="{({ detail: token }) => {
          localStorage.setItem('token', token);
          window.location.reload();
        }}"
      />
    {/if}
  </div>
{:else}
<!-- Add code to show user information -->
{/if}
Enter fullscreen mode Exit fullscreen mode

We should now have a working auth page. But we're not done yet! We still need to fetch the user from the server!

Here's the final code for that:

Conclusion

And that's it! We're done with a basic, simple, magic-link sign in. But do note that this method here isn't optimised for production or anything, it is just an introduction to email sign in.

In a realworld app, you should NEVER store the JWT in localStorage. Always use cookies, or use express-session.

If you got stuck somewhere, checkout the Github repo, and feel free to give your thoughts in the comments!

Discussion (1)

Collapse
epigene profile image
Augusts Bautra

Thanks for the writeup, but I'd recommend people not use magic links, especially if they control access to paid features/finances.
This is because magic links are in essence sending user's password (or its hash) back to them in email, so a login page can be pre-filled. Emails are notoriously lacking in the encryption department (sent between servers without SSL).
A quick google search shows that some sort of authenticator app is much safer because it reaps the benefits of two-factor security and would use encryption. auth0.com/blog/is-passwordless-aut...

Forem Open with the Forem app