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

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:

W3.CSS for the styling.

NodeJS typescript for the …

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

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

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

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 \
  • (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
  • 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"
}
  • (TYPESCRIPT ONLY) Add a tsconfig.json
npx tsconfig.json
# select "node" from the options

Add this to compilerOptions in your tsconfig:

"skipLibCheck": true

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



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)
);



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

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"));



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>

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;



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;



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;

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));

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 });
  • Add some code to check if there’s a user with that email
  const userId = (await User.findOne({email}))?.id;

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);
  • 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();
  • 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
}
  • 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");
  });

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>

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

  • Import sign and verify from jsonwebtoken
import {sign, verify} from "jsonwebtoken";
  • 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})
});
  • 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 });
  • 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 });
});

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" />

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>

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");
    }
  }

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}

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!


Print Share Comment Cite Upload Translate
APA
arnu515 | Sciencx (2024-03-29T00:06:50+00:00) » Implement password-less authentication in your apps (magic sign in). Retrieved from https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/.
MLA
" » Implement password-less authentication in your apps (magic sign in)." arnu515 | Sciencx - Friday April 30, 2021, https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/
HARVARD
arnu515 | Sciencx Friday April 30, 2021 » Implement password-less authentication in your apps (magic sign in)., viewed 2024-03-29T00:06:50+00:00,<https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/>
VANCOUVER
arnu515 | Sciencx - » Implement password-less authentication in your apps (magic sign in). [Internet]. [Accessed 2024-03-29T00:06:50+00:00]. Available from: https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/
CHICAGO
" » Implement password-less authentication in your apps (magic sign in)." arnu515 | Sciencx - Accessed 2024-03-29T00:06:50+00:00. https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/
IEEE
" » Implement password-less authentication in your apps (magic sign in)." arnu515 | Sciencx [Online]. Available: https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/. [Accessed: 2024-03-29T00:06:50+00:00]
rf:citation
» Implement password-less authentication in your apps (magic sign in) | arnu515 | Sciencx | https://www.scien.cx/2021/04/30/implement-password-less-authentication-in-your-apps-magic-sign-in/ | 2024-03-29T00:06:50+00:00
https://github.com/addpipe/simple-recorderjs-demo