DEV Community

loading...
Cover image for Implementing Passwordless Authentication in Node.JS

Implementing Passwordless Authentication in Node.JS

flippedcoding profile image Milecia ・6 min read

Broken authentication is the second-highest security risk for web applications. This usually means that session management and authentication aren't handled correctly. This gives attackers several avenues to get access to data they can use maliciously.

That's why it is important to make sure you get the best practices in place as early in the development process as possible. You can do a few things to make your authentication process more secure and protect your users. We'll go over a few of those things with a quick Node.js app.

First, let's go over some of the different ways you can handle authentication.

Authentication methods

There are a few different types of authentication methods you can choose from: session-based, token-based, and passwordless. Each of these authentication methods has its pros and cons and we'll go over a few of them.

Session-based authentication

This is the most common form of authentication. It only requires a username and password that match what's in a database. If a user enters the correct set of login credentials, they will have a session initialized for them with a specific ID. A session is typically ended when a user logs out of the app.

When sessions are implemented correctly, they will automatically expire after a set amount of time. You'll see this a lot in finance apps, like banking and trading. This gives users an added layer of security in case they've logged into their bank account on a public computer and forgot about that tab.

Token-based authentication

Instead of using actual credentials to authenticate requests, token-based authentication gives users a temporary token that's stored in the browser. This token is typically a JWT (JSON Web Token) that contains all of the information an endpoint will need to validate a user.

Every request that a user makes will include that token. One of the benefits of using a token is that it can have embedded information about what roles and permissions a user might have without fetching that data from a database. This gives attackers less access to critical information, even if they are able to steal a user's token.

Passwordless authentication

This form of authentication is completely different from the others. There is no need for credentials to log in. All you need is an email address or phone number associated with an account and you will get a magic link or one-time password each time you want to log in. As soon as you click the link, you'll get redirected to the app and you'll already be logged in. After that, the magic link isn't valid so no one else can use it.

When the magic link is generated, a JWT is also generated with it. This is how the authentication happens. With this login method, it's a lot harder for attackers to hack their way into your system. There are fewer inputs for them to take advantage of and sending the JWT through the magic link makes them harder to intercept than sending them through a response.

Now that you know about these different authentication methods, let's implement a passwordless authentication model.

Implementing authentication in Node

Passwordless authentication flow

We'll start by going through the process flow of passwordless authentication.

  • A user submits their email address or phone number in the web app.
  • They are sent a magic link to log in with.
  • The user clicks the magic link and they are redirected to the app, already logged in.

Now that we have the flow we need to implement, let's start by making a super basic front-end.

Front-end setup

We don't even need to use a JavaScript framework since the focus is mostly on the back-end. So we'll use some basic HTML and JavaScript to make the front-end.

Here's what the user interface code will be. Just a small HTML file that uses a frontend.js file.

<!DOCTYPE html>
<html>
    <head>
        <title>Passwordless Authentication</title>
        <script src="./frontend.js"></script>
    </head>
    <body>
        <h1>This is where you'll put your email to get a magic link.</h1>
        <form>
            <div>
                <label for="email_address">Enter your email address</label>
                <input type="email" id="email_address" />
            </div>
            <button type="submit" id="submit_email">Get magic link</button>
        </form>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This is what the frontend.js file will look like.

window.onload = () => {
  const submitButton = document.getElementById("submit_email");
  const emailInput = document.getElementById("email_address")
  submitButton.addEventListener("click", handleAuth);
  /** This function submits the request to the server for sending the user a magic link.
   * Params: email address
   * Returns: message
   */
  async function handleAuth() {
    const message = await axios.post("http://localhost:4300/login", {
      email: emailInput.value
    });
    return message;
  }
};
Enter fullscreen mode Exit fullscreen mode

The JavaScript file gets the submit button we made in the HTML file and adds a click event listener to it. So when the button is clicked, we'll make a POST request to the server we have running on http://localhost:4300 at the login endpoint with the email address entered. Then, if the POST request is successful, we will get a message back we can show to the user.

Back-end setup

Now we're going to start making our Node app. We'll start by making an express app and installing a few packages.

import cors from "cors";
import express from "express";

const PORT = process.env.PORT || 4000;
const app = express();

// Set up middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

// Login endpoint
app.post("/login", (req, res) => {
  const email = req.body.email;

  if (!email) {
    res.statusCode(403);
    res.send({
      message: "There is no email address that matches this.",
    });
  }

  if (email) {
    res.statusCode(200);
    res.send(email);
  }
});

// Start up the server on the port defined in the environment
const server = app.listen(PORT, () => {
  console.info("Server running on port " + PORT)
})

export default server 
Enter fullscreen mode Exit fullscreen mode

With this basic server in place, we can start adding more functionality. Let's go ahead and add the email service we're going to use. First, add nodemailer to your package.json and then import it.

import nodeMailer from "nodemailer";

Then below the middleware, we'll make a transporter to send emails. This code configures nodemailer and makes the email template with some simple HTML.

// Set up email
const transport = nodeMailer.createTransport({
  host: process.env.EMAIL_HOST,
  port: 587,
  auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASSWORD
  }
});

// Make email template for magic link
const emailTemplate = ({ username, link }) => `
  <h2>Hey ${username}</h2>
  <p>Here's the login link you just requested:</p>
  <p>${link}</p>
`
Enter fullscreen mode Exit fullscreen mode

Next, we need to make our token that holds the user's info. This is just an example of some of the basic things you might include in a token. You could also include things like, user permissions, special access keys, and other information that might be used in your app.

// Generate token
const makeToken = (email) => {
  const expirationDate = new Date();
  expirationDate.setHours(new Date().getHours() + 1);
  return jwt.sign({ email, expirationDate }, process.env.JWT_SECRET_KEY);
};
Enter fullscreen mode Exit fullscreen mode

Now we can update the login endpoint to send a magic link to registered users and they'll be logged in to the app as soon as they click it.

// Login endpoint
app.post("/login", (req, res) => {
  const { email } = req.body;
  if (!email) {
    res.status(404);
    res.send({
      message: "You didn't enter a valid email address.",
    });
  }
  const token = makeToken(email);
  const mailOptions = {
    from: "You Know",
    html: emailTemplate({
      email,
      link: `http://localhost:8080/account?token=${token}`,
    }),
    subject: "Your Magic Link",
    to: email,
  };
  return transport.sendMail(mailOptions, (error) => {
    if (error) {
      res.status(404);
      res.send("Can't send email.");
    } else {
      res.status(200);
      res.send(`Magic link sent. : http://localhost:8080/account?token=${token}`);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

There are only two more things we need to add to the code to get the server finished. Let's add an account endpoint. Then we'll add a simple authentication method.

// Get account information
app.get("/account", (req, res) => {
  isAuthenticated(req, res)
});
Enter fullscreen mode Exit fullscreen mode

This gets the user's token from the front-end and calls the authentication function.

const isAuthenticated = (req, res) => {  const { token } = req.query
  if (!token) {
    res.status(403)
    res.send("Can't verify user.")
    return
  }
  let decoded
  try {
    decoded = jwt.verify(token, process.env.JWT_SECRET_KEY)
  }
  catch {
    res.status(403)
    res.send("Invalid auth credentials.")
    return
  }
  if (!decoded.hasOwnProperty("email") || !decoded.hasOwnProperty("expirationDate")) {
    res.status(403)
    res.send("Invalid auth credentials.")
    return
  }
  const { expirationDate } = decoded
  if (expirationDate < new Date()) {
    res.status(403)
    res.send("Token has expired.")
    return
  }
  res.status(200)
  res.send("User has been validated.")
}
Enter fullscreen mode Exit fullscreen mode

This authentication check gets the user's token from the URL query and tries to decode it with the secret that was used to create it. If that fails, it returns an error message to the front-end. If the token is successfully decoded, a few more checks occur and then the user is authenticated and has access to the app!

Best practices for existing authentication systems

Passwordless authentication might not be possible for existing systems, but there are things you can do to make your apps more secure.

  • Increase the complexity requirements of passwords.
  • Use two-factor authentication.
  • Require passwords to be changed after a certain amount of time.

Conclusion

There are a lot of different ways you can implement an authentication system for your app and passwordless is just one of those. Token-based is another commonly used type of authentication and there are plenty of ways to handle this.

Making your own authentication system might take more work than you have time for. There are a lot of existing libraries and services that you can use to integrate authentication in your app. Some of the most commonly used ones are Passport.js and Auth0.

Discussion (28)

pic
Editor guide
Collapse
darkwiiplayer profile image
DarkWiiPlayer • Edited
  • A user submits their email address or phone number in the web app.
  • They are sent a magic link to log in with.
  • The user clicks the magic link and they are redirected to the app, already logged in.

Sounds good on paper, but gets infuriatingly impractical very quickly:

  • I want to log in at some random computer
  • I enter my email
  • I get an email on my phone
  • I click the link
  • I am now logged in on my phone
  • I sigh

Now I have to enter a password anyway, and deal with whatever added security my email account has. What's worse, I now have to input my email password on some random computer (compromised until proven otherwise), instead of a password for some random application I don't even care about. This isn't an improvement in any way.

A better workflow would be:

  • A user submits their email address or phone number
  • They are sent a magic link
  • When they open said link, on any device, their login clears on whatever device they initiated it
Collapse
bugs_bunny profile image
Derek Oware • Edited

I realised this the first time I did this myself. So I send both the magic link and an OTP
So the user can enter the Pincode if he/she is in such a situation
OR
The link can just verify the login attempt. After the attempt has been verified then you log the user in on the device he/she initiated the login attempt. You can use sockets to make this happen

Collapse
steelvoltage profile image
Brian Barbour

How do you go about implementing that last part? Where clicking the magic link on one device logs you in on another?

Collapse
darkwiiplayer profile image
DarkWiiPlayer

Not without some degree of back-end persistence, which I assume is the main reason it's not how what the article does. You'd need to create some sort of short-lived state in the back-end that gets cleared by opening a link in the email. The login-window could then just do polling, or use some more sophisticated method for waiting for the server to grant it access.

Collapse
shaijut profile image
Shaiju T
  • I want to log in at some random computer
  • I enter my email
  • I get an email on my phone - Why instead open your email in random computer ?
Collapse
darkwiiplayer profile image
DarkWiiPlayer

I just don't open my email on some random computer. That's the whole point: if the default is having to type in the password of your some service on some random computer, suddenly having to instead type in the password to my main email account is not an improvement; it's a reason to stop using that application and look for alternatives.

Collapse
seanolad profile image
Sean

I agree.

Collapse
syylaurence profile image
Laurence Ivan Sy

I agree that this is a better workflow

Collapse
idrisrampurawala profile image
Idris Rampurawala

Nice article! Leaving about whether this method is safe or not, as already in discussion by our dev members, I want to highlight one thing here.

In /login API (snippet below), if an email is not found, then we should not tell the user that the email is not in our system. This is a security risk by which a hacker can identify valid emails in a system.

// Login endpoint
app.post("/login", (req, res) => {
  const email = req.body.email;

  if (!email) {
    res.statusCode(403);
    res.send({
      message: "There is no email address that matches this.",
    });
  }

  if (email) {
    res.statusCode(200);
    res.send(email);
  }
});
Enter fullscreen mode Exit fullscreen mode

Hence, /login API can just respond with something generic like, You will receive an email with the link if an account is found associated with this email. 😁

Collapse
andreasvirkus profile image
ajv

Excellent point to bring up! Same goes for signups. You should always say "You'll receive an email" and for existing emails, simply state that "Someone tried to sign up with us. If that was you - Log in here instead"

That way a malicious user/attacker can't enumerate the existing emails at a large scale

Collapse
karlkras profile image
Karl Krasnowsky

Yeah, I know this is the preferred "safe" response, but can be frustrating when you're "sure" that was the email (I have many) and don't get a response.
I would prefer an email be sent anyway with a message telling me that the email provided wasn't registered so at least I know the process is working.
I don't see a security problem with this approach? Though it may result in an increase of bounced emails from fat fingered entries.

Collapse
nanasv profile image
Nana aka y_chris

if I was a hacker, I should know from the message that my details is not, hence no email with some link.
so to me it's still the same.

Collapse
wparad profile image
Warren Parad

I was so hoping this would talk about how to easily integrate WebAuthN, but instead unfortunately, it's sharing more about the bad suggestion of legacy passwordless.

SMS is unsafe, email is vulnerable and provides bad UX. There's way more problems than this, but they've been listed many times before

Collapse
wonsil profile image
Mark Wonsil

It is no longer a best practice to force a password change after a period of time. This according to NIST, Microsoft, and the man who suggested the idea in the first place.

Collapse
Sloan, the sloth mascot
Comment deleted
Collapse
wonsil profile image
Mark Wonsil

See Best practices for existing authentication systems, the paragraph before the conclusion.

Collapse
aubs profile image
Aubrey

The described solution actually introduces attack vectors; what if someone (else) has access to email / phone? They will be able to login.

Someone said "There is a barrier to access a mailbox.".. there is not! If I already have your mailbox then I can get the app "access" if an email is sent for the access... This is a main reason for 2fa and such. (the same goes for phone number).

The only real solution for passwordless (sic) authentication is either an app or a (usb)key. See for example the auth0 implementation for details. (or the MS Authenticator).

The idea of implementing an own authentication layer is outdated anyway.
Why? You do not want a crappy/outdated implementation, that you need to keep updated and secure, to expose your credential data.

Collapse
andreasvirkus profile image
ajv

Whilst the idea of implementing your own authentication may seem outdated to you, it's
1) always good to understand how the services/libraries you use work behind the scenes
2) important to understand that often you'll require custom solutions and services like Auth0 are very rigid in certain regards

Collapse
bigbott profile image
bigbott

Dangerous.
The attacker steals the mailbox and gets access to all applications/websites.

I think the best way is a custom stateless JWT that contains encrypted userID and timestamp and included in the request as both Cookie and a part of the request body (JSON). The server then compares JWT from Cookie and JSON and if they match -- keeps the user logged in and retrieves needed info from DB with userID.

Collapse
arswaw profile image
Arswaw

A fine article, for a topic that is only becoming more popular.

Collapse
cristhos profile image
Br CRISTAL DIBWE

This concept is true for the phone number, but I don't think so with email.

There is a barrier to access a mailbox.

Collapse
crazyoptimist profile image
CrazyOptimist • Edited

@darkwiiplayer You could check this:
magic.link/
But it's too costly I think :)

Collapse
darkwiiplayer profile image
DarkWiiPlayer

I mean, if you're going to pay for anything, the extra cost of having a small database isn't really a big deal. It's not like it's hard to build such a login system on its own if you accept that you'll have to persist some data, which is what the internet has mostly been trying to get away from.

Collapse
carcruz profile image
Carlos Cruz

Great article 👍

Collapse
nanasv profile image
Nana aka y_chris

hello Melicia, I have gone through your work and it's awesome.
Just that I could not see where user email was verify from the data base, before login in automatic.

Collapse
seanolad profile image
Sean

Nice post, very helpful as a refresher. 😄

Collapse
mardeg profile image
Mardeg

I thought this would be a tutorial on the Node.js implementation of SQRL.
Too bad.

Collapse
denizbinay profile image
Deniz Binay

Is there a good passwordless implementation? Gladly open source. Auth0-Passwordless is a pay-service and the passwordless strategy from password js has not been updated for 5 years.