Passwordless login
August 19th, 2022
Password-less authentication, also known as login with email or magic link, is an authentication mechanism to log users in with only their email. There’s no password involved as users only have to click on a special link in their email inbox to login to you app.
It’s a controversial topic among users. Some people hate it because they don’t want to open their email client every time they need to login, while others love it because it removes the need for password and is more secure.
Whether you like it or not, you might be asked by your client/boss to implement this so today let’s see how to implement “Login with email” functionality using JavaScript and NodeJS. I know there are so many managed libraries and auth services that can do this but it’s great to be able to dissect this and build it on your own.
In a few weeks, I’ll also write a guide on how to do magic links where you click on the link in the email and your current browser auto signs in, even if the click was on a different device. I’ll tweet about it when I release it.
How does password-less login (or magic links) work
It’s a very simple idea essentially. User requests a login link to their email which upon clicking logs then in magically without the need for passwords.
The flow looks like this in detail
- User opens authenticated page but get’s redirected to
- /login
- page
- User fills their email on the login form
- The backend verifies their email and sends them a special login link (magic link)
- User clicks on the login link which is handled by the backend which verifies the token in the link and logs user in by setting their session
- User gets redirected to their account page
The main ingredients for implementing password-less auth are
- A web form that collects email
- A backend api that verifies the account and sends login token via email
- A backend api that receives the login token and creates user session
- Some way to send emails to users
- Some way to generate secure JWT tokens
Step 0: setup base code
Let’s get started. I’ll be implementing this using NodeJS/JavaScript but you can easily adapt the concepts in your fav language. I’ll be using expressJS to handle routine, jsonwebtokens for JWT tokens management and nodemailer for sending emails.
We start with a very simple expressJS app that will be handling both our html and api routes. I’m assuming you know how to setup a basic nodejs project so skipping some generic details.
I’ve listed down the routes that we’ll be needing.
const express = require("express");
const app = express();
//Your authenticated page route
app.get("/", (req, res) => {
//todo
});
app.get("/login", (req, res) => {
//todo
});
app.post("/api/login-with-email", (req, res) => {
//todo
});
app.get("/api/login-with-email", (req, res) => {
//todo
});
app.get("/logout", (req, res) => {
//todo
});
app.listen(3000);
Step 1: create the web form that collects email
Let’s create a simple html file (login.html) that will be rendered on /login route
This contains an HTML form that takes email and sends it to our backend API (POST /api/login-with-email)
This might be long but it’s very simple html. I used TailwindCSS for style that’s included via cdn and also imported some Google fonts. All of this is optional and it’s just so the page look decent enough.
<!-- login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>How to do password-less authentication</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800;900&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<style type="text/tailwindcss">
body {
font-family: "Inter", sans-serif;
}
</style>
</head>
<body
class="h-screen w-screen flex flex-col items-center justify-center bg-gray-50"
>
<div
class="flex flex-col w-full max-w-md bg-white p-12 shadow-xl rounded-lg gap-4"
>
<h1 class="font-black tracking-wide text-xl text-center text-indigo-500">
Login with Email
</h1>
<form class="space-y-4" id="form">
<p class="text-center text-sm">
Enter your email and we'll send you a login link to your inbox
</p>
<label class="flex flex-col">
<span class="text-sm">Email</span>
<input
type="email"
name="email"
required
class="border rounded border-gray-200 p-4"
placeholder="email@you.com"
id="email"
/>
</label>
<button
id="btn"
class="w-full text-white rounded font-bold hover:bg-gray-700 bg-gray-800 p-4"
>
Send Magic Link
</button>
</form>
<a id="success" class="text-center hidden underline">
Request sent! Check the dummy inbox here
</a>
</div>
</body>
<script>
var form = document.getElementById("form");
var email = document.getElementById("email");
var success = document.getElementById("success");
var btn = document.getElementById("btn");
async function handleSubmit(e) {
e.preventDefault();
btn.disabled = true;
btn.innerText = "Sending link....";
fetch("/api/login-with-email", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ email: email.value }),
})
.then((d) => d.json())
.then((d) => {
form.classList.add("hidden");
success.href = d.debugEmailLink;
success.classList.remove("hidden");
});
}
form.addEventListener("submit", handleSubmit);
</script>
</html>
This code renders an HTML form like this

We can update our express routes to now render this file on /login route
const path = require("path");
...
app.get("/login", (_, res) => {
res.sendFile(path.join(__dirname, "/login.html"));
});
...
Step 2: create backend API to receive this request
POST /api/login-with-email
Here’s a high level logic this route needs to do
- check whether the email exists as an account in your system
- generate a special JWT token that contains some metadata about this account
- Email the token to the user. Email should contain the full endpoint containing the token that our backend will handle. It should looks like this
http://yourdomain.com/api/login-with-email?token=secrettokenthatlogsuserin
Notes
- we can also put IP based rate limits on this endpoint so people can’t abuse it
- Recaptcha can be added to prevent bot access
- The login token should ideally have an expiry date to improve security.
Here’s how this all looks like in code
const mailer = require("./mailer");
const tokenHandler = require("./token");
const database = require("./database");
//POST route that generates and sends the magic login token to the provided email
app.post("/api/login-with-email", async (req, res) => {
const { email } = req.body;
//Valid email
if (!email) {
return res.status(400).json({ error: "Invalid email" });
}
//Check account in db
const account = database.getAccount(email);
if (!account) {
//Silently ignore
return res.status(200).json({ ok: true });
}
const magicLink = tokenHandler.generateToken(email);
const debugEmailLink = await mailer.sendMagicToken(magicLink, email);
res.json({ ok: true, debugEmailLink });
});
My database package is pretty simple but here you’ll be calling your database
const getAccount = (email) => {
//Check your database to ensure this email
//has a valid account.
//Returning dummy here
return {
email,
name: "Tim Apple",
id: "some-id",
};
};
module.exports = {
getAccount,
};
token.js is responsible for generating login tokens using a JWT library. I’m using jsonwebtokens for this which is pretty simple to use. token.js has only two functions, one to generate a token given the email and second to verify incoming token.
const jwt = require("jsonwebtoken");
//This will come from your secrets manager in production
const signingSecret = "supersecret123456";
const generateToken = (email) => {
const token = jwt.sign({ email }, signingSecret, {
expiresIn: "10m", //expires in 10 minutes
});
return token;
};
const verifyToken = (token) => {
try {
return jwt.verify(token, signingSecret);
} catch (err) {
return null;
}
};
module.exports = {
generateToken,
verifyToken,
};
For sending emails I created a small file called mailer.js which just uses nodemailer
lib for sending emails via SMTP protocol. You can use whatever email method you have in your system (like Sendgrid, Mailgun etc).
In our mailer code we only have two funcs, one to initialize the system and other to send the magic token to provided email. I also added a dummy email inbox using ethereal.email which makes it easy to debug emails. In production your setup won’t include that hopefully.
const nodemailer = require("nodemailer");
const APP_HOST = "http://localhost:3000";
let testAccount;
let transporter;
const init = async () => {
if (!testAccount) {
testAccount = await nodemailer.createTestAccount();
}
transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
};
const sendMagicToken = async (token, email) => {
let info = await transporter.sendMail({
from: "support.yourdomain.com",
to: email,
subject: "Your login link for our Service",
html: `
<p>Click on the link below to login to your account.</p>
<a href="${APP_HOST}/api/login-with-email?token=${token}">Click here to login</a>
`,
});
const previewUrl = nodemailer.getTestMessageUrl(info);
console.log(`Message sent: preview it here: ${previewUrl}`);
return previewUrl;
};
module.exports = {
init,
sendMagicToken,
};
Note how we set APP_HOST to our main domain where we host our application. Thats because email link has to be a fully qualified url that is accessible from user’s browser. So it can’t be a relative link. the APP_HOST value will probably be like https://yourdomain.com in production and http://localhost:3000 in dev mode
Step 3: Setup cookie session middlewares and authenticated page
Before we go further let’s setup some session middleware responsible for reading user’s cookie and setting up the session. Also we’d like to block our main page if user is not logged in and just redirect them to the login page instead.
As noted earlier we are using cookie based session which I prefer because it makes things a lot simpler. If you are using other methods like JWT token or some other system you’ll have to modify this part a bit yourself.
First thing to do is to setup our session middleware. I’m using iron-session here which provides us an encrypted and opaque cookie that can only be read from our backend.
const { ironSession } = require("iron-session/express");
const COOKIE_SECRET = "complex_password_at_least_32_characters_long";
const sessionMiddleware = ironSession({
cookieName: "appSession",
password: COOKIE_SECRET,
});
Then we modify our top / route to include this middleware and then check for user’s session. If not present then we redirect to login page
const indexHtml = fs
.readFileSync(path.join(__dirname, "/index.html"))
.toString();
//Your authenticated page route
app.get("/", sessionMiddleware, (req, res) => {
//<- iron-session middleware auto attaches this 'session' obj to req
if (!req.session.user) {
return res.redirect("/login");
}
res.setHeader("content-type", "text/html");
//Doing some poor man's template rendering
const content = indexHtml.replace("{{ email }}", req.session.user.email);
return res.send(content);
});
The index.html is just a simple page I created that represents the “logged in” view for the user. Note how I’m replacing the {{ email }} with the req.session.user.email just to indicate that we can send this kind of session data to the html layer. I’m doing this by simply replacing a template using .replace() as a poor man’s templating system. Obviously your app will have more sophisticated way of rendering templates.
Here’s how the index.html looks like btw.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>How to do password-less authentication</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800;900&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<style type="text/tailwindcss">
body {
font-family: "Inter", sans-serif;
}
</style>
</head>
<body
class="h-screen w-screen flex flex-col items-center justify-center bg-gray-50"
>
<div
class="flex flex-col w-full max-w-md bg-white p-12 shadow-xl rounded-lg gap-4"
>
<h1 class="font-black tracking-wide text-xl text-center text-indigo-500">
Hurray! you are logged in
</h1>
<span>Logged in as {{ email }}</span>
<a class="underline text-sm" href="/logout">Click to logout</a>
</div>
</body>
</html>
This is how it looks like when rendered

Step 4: create backend api that receives the login token and creates the session
Assuming that user clicked on the login link in the email which should bring them to this api endpoint
First ensure that token exists
Then verify the token using your JWT signing code which should fail if the token in invalid or expired. Return error if invalid
Now that we have a valid token, read the contents of it, get the email and check again with your db (optional) and then create the session for this user.
//GET route that verifies incoming token and sets the user session
app.get("/api/login-with-email", sessionMiddleware, async (req, res) => {
const { token } = req.query;
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
//Verify token
const tokenData = tokenHandler.verifyToken(token);
if (!tokenData) {
return res.status(401).json({ error: "Unauthorized" });
}
//Set session
const account = database.getAccount(tokenData.email);
req.session.user = account;
await req.session.save();
res.redirect("/");
});
I also added a logout route to destroy the session
app.get("/logout", sessionMiddleware, async (req, res) => {
await req.session.destroy();
res.redirect("/login");
});
How to create the session?
It depends on how sessions work in your system. These are the most common ones used
- Stateless cookie based session where you store an encrypted cookie on user’s browser (using iron-session)
- Stateful cookien based session where you store session data on your servers and send an opaque session id in the cookie
- JWT token.
Hi,
I'm Kashif 👋
I'm the founder of NameGrab, Mono, and OneDomain
I've been in working tech for about 11 years, and I love building startups from scratch and sharing my thoughts here.
