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 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
orpnpm
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 inpackage.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.
- First, we ask the user for their email. This happens on the frontend.
- Next, the frontend sends the email to the backend.
- 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.
- The backend generates a code for the user, and stores it in the database. It sends the code to the user via email.
- The email contains a link to get the user authenticated. This link may/maynot need a code. Hence, the term magic link.
- 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 functionasync
.
- 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
andverify
fromjsonwebtoken
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!
Top comments (1)
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...