/ 

Ref/google authenticator!

How to Integrate Your Node/Express Website with Google Authenticator for Two-Factor Authentication in 2022

URL copied to clipboard
By AstroMacGuffin dated  last updated 

fingerprint-g27965da2f_1280.jpg I've been making personal websites off-and-on since the 90's, so one can probably assume I've lost websites to hackers in the past. Once the rootkit is installed on your host and the sketchy ads are in your articles, it's very difficult to get them all out. So security is best handled as a preventative measure, not a reactive one. In this article, I'll walk you through the entire process of integrating with Google Authenticator for 2FA (two-factor authentication). I'll be using MongoDB, Node.js, and Express.js.

If you've seen other articles covering this topic, you may have noticed that they use speakeasy, an NPM package that hasn't been updated in 7 years. That's not the best look for a security kit. Thankfully, another author has forked that package and updated it just this year, so we're good to go. Other articles also tend not to cover the front-end, which is crucial to understanding how 2FA is even supposed to work. This article takes you through the entire integration.

Ingredients

  • your existing login system
  • the NPM package express-session, which you should already be using in your login system
  • the NPM package connect-mongo, probably
  • the NPM package @levminer/speakeasy, a fork of speakeasy
  • the NPM package qrcode
  • a couple of database methods
  • a significant rejiggering of the registration and login processes


Basic Orientation

Maybe this is your first experience with Two-Factor Authentication (2FA). Let's start slow, and explain things as we go:

What Is Two-Factor Authentication?

For those of you not up to speed, 2FA means the login has two prompts: one for username and password, and then another prompt for a separate secret. In theory (and practice for companies in the Forbes 500 list) this can mean thumbprints, retinal scans, heck why not even prick a finger and analyze some DNA… in reality, it means whipping out your phone, looking at Google Authenticator, and punching in a 6-digit code. It's a quick and painless process once you get used to it.

What are the Advantages of 2FA?

Two-factor authentication protects users and website owners from weak or stolen passwords, by using decently-strong encryption on a physical key. In this case, the key is a smartphone.

What is Google Authenticator?

Google Authenticator is a free smartphone app by Google. It retrieves encryption settings from a website by scanning a QR Code. The QR Code is a very important secret, like a password, except that the QR Code must be unique to each user. Once configured, Google Authenticator then issues 6-digit codes, which are entered as a "second password" for logging into 2FA-enabled sites and apps. The codes spawn and expire based on 30-second timers.

The Nitty Gritty: NPM Packages Used in 2FA

Aside from the usual suspects, i.e. Express and your chosen database connector library, there are three (or four) NPM packages you'll need for this feature.

2FA Validation & URL Generation by @levminer/speakeasy

After 7 years of no updates to the speakeasy library, Github user @levminer has forked the project, reviving and modernizing it. They've also upgraded the speakeasy documentation significantly. This library provides the following things that are relevant to our technique for enabling 2FA:

  • creation of a shared secret, needed to synchronize encryption/validation between Google Authenticator and your website
  • validation of tokens against the shared secret
  • creation of an otpauth URL for configuring Google Authenticator

@levminer/speakeasy also handles creation of time-based tokens, useful to those who send the 6-digit code over SMS texts or some other method instead of Google Authenticator; as well as another token system for one-time passwords. But we'll be using only the above three features.

To install:

npm install @levminer/speakeasy

Documentation for @levminer/speakeasy

QR Code Generation by qrcode

This one takes very little explaining. My favorite part of qrcode is that it can output the code in SVG format. My least favorite part is that the method for creating the QR Code, requires a callback to receive the code. This made things slightly tricky in terms of rendering an Express.js template that included the code. You'll see how I solved that, below. The QR Code we generate will encode the URL created by @levminer/speakeasy, which tells Google Authenticator how to generate valid 6-digit codes for your website.

To install:

npm install qrcode

Documentation for qrcode

Sessions by express-session

A "session" is web tech speak for a server-side variable that links to a client-side cookie. The cookie contains only enough info to identify the session. The session can then be used to store whatever data you want to link to that visitor or account. Is the user logged in? Who as? Is that user an admin? Etc. It's very convenient and reasonably secure, especially compared to the alternative of storing all this data in cookies. The express-session package is insanely easy to use; just enable the middleware and you instantly have a working session system with variables automatically populated into req.session.

To install:

npm install express-session

Documentation for express-session

If you need a quick-start for express-session, just put this into your main JS file (the one that you run with node [filename], aka the target of your npm start script as found in package.json):

const session = require('express-session')({
  cookie: { sameSite: false },
  secret: 'funky ol gorilla',
});
// use sessions
app.use(session);

Session Data Storage by connect-mongo

But where will the session data be stored if the server restarts? By default, that data would be lost and everyone would be logged out of your website. You need database storage for your session data, to solve this problem, and that's where connect-mongo comes in. Again, this library is insanely easy to use. You instantiate an object as part of your initialization for express-session, and you're done. It automatically creates a sessions collection in your database and keeps it up to date as part of the express-session task load.

To install:

npm install connect-mongo

Documentation for connect-mongo

If you need a quick-start for connect-mongo: remember the code I told you to put into your main JS file above? Modify it like so:

const MongoStore = require('connect-mongo');
const session = require('express-session')({
  cookie: { sameSite: false },
  secret: 'funky ol gorilla',
  store: MongoStore.create({
    mongoUrl: `YOUR MONGO CONNECTION STRING`,
  }),
});
// use sessions
app.use(session);

Be sure to change the mongoUrl field to your actual MongoDB connection string, which is made of five fields:

  • your MongoDB USERNAME
  • your MongoDB PASSWORD
  • the HOST the MongoDB server runs on (usually 127.0.0.1)
  • the PORT the MongoDB server listens on (usually 27017)
  • the DATABASE_NAME relevant to this project

You put them together like the following:

mongodb://USERNAME:PASSWORD@HOST:PORT/DATABASE_NAME

jscode-gef39f2b51_1280.jpg

Now, Where To Begin?

I felt it necessary to start with a quick reality check, to make sure I understood the documentation correctly. So rather than hacking up my existing code, I created a new Node.js script, and started cherry-picking from the documentation for @levminer/speakeasy and qrcode. Here's the result:

const speakeasy = require('@levminer/speakeasy');
const QRCode = require('qrcode');
const secret = speakeasy.generateSecret({ length: 32 });
const token = speakeasy.totp({
    secret: secret.ascii,
    encoding: "ascii",
});
console.log(`token: ${token}`);
const tokenValidates = speakeasy.totp.verify({
    secret: secret.base32,
    encoding: "base32",
    token: token,
    window: 2,
});
console.log(tokenValidates);
const url = speakeasy.otpauthURL({
   secret: secret.ascii, label: "AstroMacGuffin.com", algorithm: "sha512"
});
console.log(url);
QRCode.toString(url, {type: 'svg'}, (err, qr) => {
  console.log(qr);
});

Let's break that down:

const speakeasy = require('@levminer/speakeasy');
const QRCode = require('qrcode');

Above, I'm just including our two rockstar libraries, as already discussed.

const secret = speakeasy.generateSecret({ length: 32 });

This secret being created above is used to configure the encryption and validation, so you'll see it being used in every step of the process below.

const token = speakeasy.totp({
    secret: secret.ascii,
    encoding: "ascii",
});
console.log(`token: ${token}`);

I needed a 6-digit code to validate, so I generated one. We won't be using the above feature, later in the article: but the above was used temporarily as a substitute for Google Authenticator. I used the .ascii version of the secret above, and the .base32 version below, just to verify that it works when you mix-and-match; it does.

const tokenValidates = speakeasy.totp.verify({
    secret: secret.base32,
    encoding: "base32",
    token: token,
    window: 2,
});
console.log(tokenValidates);

Above, we check whether the token validates -- the token being our 6-digit code, that is. This is how you'll validate the codes from Google Authenticator, too. The window option specifies how old the code can be before it's rejected. Each window is 30 seconds long by default, the same length of time Google Authenticator uses. This length of time is configurable, but we're going to leave it alone for compatibility with Google Authenticator.

const url = speakeasy.otpauthURL({
   secret: secret.ascii, label: "AstroMacGuffin.com", algorithm: "sha512"
});
console.log(url);

Above, we generate a URL for Google Authenticator or any compatible app. The URL starts with the otpauth protocol, and includes the label, the ASCII version of the secret, and the algorithm name.

Let's pause for a moment. Let's say you're a person who usually browses the web on a mobile device. Along comes a website that wants you to use two-factor authentication … which means they want you to scan a QR Code. How are you supposed to do that when the QR Code is being displayed on the same device that has your camera and runs the authenticator app? Are you supposed to point another phone at this phone? One phone for Google Authenticator, another phone for browsing? No. Google Authenticator registers itself as the default handler for otpauth links, which means webmasters should be using responsive CSS code to show the QR Code on PC and a plain old link to the otpauth URL on mobile.

Finally, about that QR Code:

QRCode.toString(url, {type: 'svg'}, (err, qr) => {
  console.log(qr);
});

As I said, this method requires a callback to receive the QR Code output. In the above, we output the QR Code as SVG to the console. It works beautifully, but we're going to have to work around it a bit in order to incorporate that output into a template. Be patient, we'll get there.

Now that we've verified that the features all work as expected and that we understand how to use them, it's time to start modifying your project.

Step 1: Your Own Personal 2FA Library

The first step is to convert the "reality check" code, into code assets for your project. I have a MiscUtils class that floats around my project as the object mu. Everything that doesn't invoke the database, will go there. So first we put the following at the top of the MiscUtils class file:

const speakeasy = require('@levminer/speakeasy');

And here are the first methods I put into MiscUtils:

  makeTOTPSecret() {
    return speakeasy.generateSecret({ length: 32 });
  }
  validateTOTP(totp, secret) {
    if (!totp || !secret) return false;
    return speakeasy.totp.verify({
        secret: secret.base32,
        encoding: "base32",
        token: totp,
        window: 2,
    });
  }

(TOTP stands for "Time-based One-Time Password".)

As you can see, this is a copy-paste of my "reality check" code, with very little modification. Thanks to the window: 2 option and the default time-step of 30 seconds, we give the user 60 seconds to enter the code; that's plenty of time. These two methods are separated because the secret must be generated at user registration, while the token/TOTP must be validated at login.

This, of course, means we must store the secret in the database. Let's get started on the database code, then. I added these methods to SiteDatabase, my object class for database operations:

  async setTOTPSecret(secret, username) {
    try {
      const d = mdb.db("yourDatabaseName");
      const t = d.collection("yourUsersCollection");
      const query = { username };
      const doc = { $set: {
        totp_secret: {
          base32: secret.base32,
          ascii: secret.ascii,
          hex: secret.hex,
        }
      }};
      await t.updateOne(query, doc, {upsert: false})
    }
    catch (e) {
      logError(`Error setting TOTP secret: ${e}`);
    }
  }
  async getTOTPSecret(username) {
    try {
      const d = mdb.db("yourDatabaseName");
      const t = d.collection("yourUsersCollection");
      const query = {username};
      let c = await t.findOne(query);
      if (!c.totp_secret) {
        const secret = mu.makeTOTPSecret();
        await this.setTOTPSecret(secret, username);
        return secret;
      }
      return c.totp_secret;
    } 
    catch (e) { 
      logError(`Error getting TOTP secret: ${e}`); 
    }
  }

Let's break that down.

  async setTOTPSecret(secret, username) {
    try {

Here we define the method signature and open a try block. The method needs to be async because we'll be await-ing some database methods inside.

      const d = mdb.db("yourDatabaseName");

mdb is an instance of MongoClient, the main workhorse of the mongodb NPM package. Replace yourDatabaseName with a recipe for cardboard cake, or your database name. Probably the latter.

      const t = d.collection("yourUsersCollection");

Replace yourUsersCollection with the name of your favorite uncle, or your users collection. Probably the latter.

      const query = { username };

This is our filter; we only want to update one record, the one with a username matching the username argument sent to this method.

      const doc = { $set: {
        totp_secret: {
          base32: secret.base32,
          ascii: secret.ascii,
          hex: secret.hex,
        }
      }};

The above is the data we're updating. The above three object fields, correspond to the three encoding styles available for the secret.

      await t.updateOne(query, doc, {upsert: false});

We don't want an upsert (which inserts a new record if there's no match for query).

    }
    catch (e) {
      logError(`Error setting TOTP secret: ${e}`);
    }
  }

Above we close out the try block, issue our catch block, and close out the method. (logError() is part of my own logger utilities.)

  async getTOTPSecret(username) {
    try {

Above we start the next method: fetching the secret from the database, for a given username.

      const d = mdb.db("yourDatabaseName");
      const t = d.collection("yourUsersCollection");
      const query = {username};

Same as in the previous method, we get an instance of the database, then an instance of the collection, and then set a query to filter our results down to only the record with a matching username.

      let c = await t.findOne(query);

Above, we do the query and capture the result in c.

      if (!c.totp_secret) {
        const secret = mu.makeTOTPSecret();
        await this.setTOTPSecret(secret, username);
        return secret;
      }

If the result doesn't have the totp_secret field, we must generate one, save it, and return it. That's what we're doing above.

      return c.totp_secret;

Otherwise, we return the totp_secret from the query result.

    } 
    catch (e) { 
      logError(`Error getting TOTP secret: ${e}`); 
    }
  }

And we close out the try block, do the catch block, and close out the method.

There's more to do in your personal code library, but we'll come back to that when it's time. Wait for it… okay, it's time.

time-and-travel.jpg

Step 2: Modifying the Registration Process for Two-Factor Authentication

It's time to show new users the QR Code they'll need for logging in with 2FA. It's crucial that your users understand there is a mandatory action they must do immediately after registration. They must prepare Google Authenticator for 2FA on this website, or else they will not be able to log in, whether that means now or in the future.

There are roughly three techniques for handling the end of the registration process:

  1. The user is automatically logged in
  2. The user is redirected to the login page
  3. The user is put in an onboarding process

It doesn't matter which you prefer, or which your app currently uses. Mine was redirecting to the login page when I started adding this feature. No matter what, the user should be shown the QR Code as soon as they complete the most basic registration step in case they do something wacky like clearing their cookies immediately after registration.

If the user gets up and goes to the bathroom and then forgets to come back, any onboarding process can pick up where it left off. But, if the user can't log in because they didn't set up the necessities for two-factor authentication, you probably won't ever see that user again… or, at best, your database will be full of unused users, plus users who had to sign up a second time, with a secondary email address.

As soon as your website's logic says "yeah, this user should be allowed to log in now", that's when you show them the QR Code and give them instructions on setting up and using 2FA to log in.

Handling the User

The anatomy of a registration process is:

  • validate & sanitize user inputs
  • reject if anything is invalid; return user to registration form and report why
  • if the inputs are all valid & sane, add the user to the database
  • perhaps log the user in
  • redirect to a start page OR display a template that begins an onboarding process

Setting up two-factor authentication is basically an onboarding process, so all roads lead there: if you were redirecting the user, you have to stop doing that and create a template that displays and explains the QR Code. If you were already onboarding the user, you need to bump step 1 down to step 2, and insert a new step 1: preparing for two-factor authentication.

For example, after adding the user to the database, my 2FA post-registration step now does this:

    const secret = await db.getTOTPSecret(usernameSafe);
    await mu.getTOTPQRCodeAndRenderTemplate(
      secret,
      res,
      'user-adduser',
      Object.assign({
        title: 'User/User Registered! | AstroMacGuffin.com',
        message: 'User/User Registered!',
        activeLink: undefined,
      }, await db.getDefaultRenderOptionsObj(req)),
      usernameSafe,
    );

There's a lot to unpack here, but mainly:

  • we've got a MiscUtils method that just appeared out of nowhere! It's the briskly-named .getTOTPQRCodeAndRenderTemplate().
  • we've got a SiteDatabase method I've never mentioned, .getDefaultRenderOptionsObj().

.getDefaultRenderOptionsObj() is simply a method that collects a bunch of data and makes it available to my templates. It saves a lot of work because now my routes don't need logic for a slew of variables and database requests.

Remember I said there was going to be a problem with QRCode.toString() because it requires a callback to receive the QR Code? I'm not sure how many solutions there were to this problem, but here's the one I chose. First, we add the following to the top of the MiscUtils file:

const QRCode = require('qrcode');

And then we add the following method:

  async getTOTPQRCodeAndRenderTemplate(secret, res, tpl, obj, username) {
    try {
      const url = speakeasy.otpauthURL({
         secret: secret.ascii,
         label: `YourWebsiteName.com (${username})`,
         algorithm: "sha512"
      });
      await QRCode.toString(url, {type: 'svg'}, (err, qr) => {
        res.render(tpl, Object.assign(obj, {qr, qrURL: url}));
      });
    } 
    catch (e) { 
      logError(`Can't make QR code: ${e}`); 
    }
  }

Let's break that down:

  async getTOTPQRCodeAndRenderTemplate(secret, res, tpl, obj, username) {
    try {

This is an asynchronous method with a long name and a lot of parameters, all of them mandatory.

  • secret is a TOTP secret, generated by speakeasy via our MiscUtils helper method.
  • res is the Express response object. We need this to render the template.
  • tpl is the name of the template to be rendered.
  • obj is the data to be passed to the template.
  • username is the already-sanitized and validated username.
      const url = speakeasy.otpauthURL({
         secret: secret.ascii,
         label: `YourWebsiteName.com (${username})`,
         algorithm: "sha512"
      });

Make sure you replace YourWebsiteName.com with your nicest pair of socks. Or, you know. Maybe your website name? The label option is what will be displayed in Google Authenticator. It should include the username for mainly your personal convenience, as we all have our admin accounts, our test accounts, and possibly more…

      await QRCode.toString(url, {type: 'svg'}, (err, qr) => {
        res.render(tpl, Object.assign(obj, {qr, qrURL: url}));
      });

And the solution to our callback problem. What's this do?

  • It uses the res object passed into the .getTOTPQRCodeAndRenderTemplate() method, to render a template.
  • Which template? tpl, as passed into the .getTOTPQRCodeAndRenderTemplate() method.
  • The obj (template data passed into the method) is merged with new data using Object.assign().
  • The new data contains both the QR code (qr) and the URL embedded in the picture (qrURL).
    } 
    catch (e) { 
      logError(`Can't make QR code: ${e}`); 
    }
  }

End the try block, do the catch block, and end the method.

Now your post-registration template just needs to be modified. Out with the redirect (if there was one) and in with the QR Code and an explanation of it and of 2FA.

Here's a tip for you Pug users: If you output the SVG QR Code the wrong way, it will display a bunch of SVG code on your output (i.e. it will display the code on your website) rather than actually using the SVG as source. Here's how to do it:

div !{qr}

And here's another tip for everyone: you can use CSS classes as conditional logic for your responsive code. That's how my post-registration page knows whether to show the QR Code, or just the link:

/* desktop */
.if-mobile {
  display: none;
}
.if-not-mobile {
  display: block;
}
@media (max-width: 1024px) {
  /* mobile */
  .if-mobile {
    display: block;
  }
  .if-not-mobile {
    display: none;
  }
}

Here's the relevant part of my Pug template, which should be self-explanatory:

        h1= message
        p Hang tight, we just need to go over 2 things:
        h2 Two-Factor Authentication Required
        div.if-not-mobile#qr(style="margin: 2.5vw auto; width: 25vw; align: center;") !{qr}
        p.
          Action required! Don't leave this page until done! 
          It's simple. Download the free Google Authenticator app on your 
          smartphone or similar device. Launch it. Click the plus-sign button 
          on the bottom-right, and choose to scan a QR code. Scan the code 
          above. That's the setup; you'll only do those steps once. 
        p.if-mobile
          strong.
            If you don't see a QR code above, click this link instead: 
          a(href=`${qrURL}`).
            You must have Google Authenticator (or a similar app) installed on 
            this device.
        p.
          Then, each time you log in to AstroMacGuffin.com, launch 
          Google Authenticator and enter the 6-digit code.

Step 3: Modifying the Login Process for Two-Factor Authentication

We're almost done! Now all that's left is to prompt for, and verify, the 6-digit code.

It's up to you whether to make a 3-field login form (username/password/2FA) or a 2-step login form (username and password on the first step, 2FA on the second step). I went with two steps.

The anatomy of a login without 2FA:

  • sanitize the username and password inputs
  • check the username and password against the database
  • if either of the two steps above fails, return the user to the login form with feedback
  • otherwise, log the user in and set whatever session variables
  • redirect the user to some start page

We need to modify that. We'll be bumping the redirect to later, inserting another step before that redirect.

Let's say, for example, that your Express.js routes are set up like this:

  • /user/login points at the login page
  • /user/dologin processes the login form once submitted

And your Pug templates, like this:

  • user-login.pug is the page with the login form.
  • user-dologin.pug is shown after successful login; it redirects the user to the home page after 3 seconds.

After we're done:

  • The Express.js route /user/login and Pug template user-login.pug will not be modified.
  • The route /user/dologin will instead serve the new template user-2faform.pug after successful username/password login.
  • /user/do2fa will be a new route which serves user-dologin.pug on successful 2FA login.

But first let's talk about session variables. The /user/dologin route was previously in charge of setting session variables that indicate to the system that the user is logged in. We still need the data from those variables, but we don't want them to be stored with the same configuration as they are in the unmodified form. I recommend simply moving them to a req.session.login object -- all the same variables, but without the side-effect of accidentally treating your users as if they're logged in, before they complete two-factor authentication.

user-2faform.pug is a whole page template containing a simple form with one input type="text" plus a submit button. It doesn't get much easier. The action of the form is, of course, /user/do2fa. We'll name the input field twofactor.

The /user/do2fa route is surprisingly simple:

router.post('/do2fa', async (req, res) => {
  if (!req.session.login || !req.session.login.username)
    return res.redirect('/user/login');
  const secret = await db.getTOTPSecret(req.session.login.username);
  const valid2fa = mu.validateTOTP(req.body.twofactor, secret);
  if (!valid2fa) {
    try {
      return res.render('user-2faform', Object.assign({
        title: '2-Factor Authentication Failed. Try again!',
        message: '2-Factor Authentication Failed. Try again!',
        activeLink: undefined,
      }, await db.getDefaultRenderOptionsObj(req)));
    } 
    catch (e) { logError(e); return; }
  }
  req.session.username = req.session.login.username;
  delete req.session.login;
  res.render('user-dologin', Object.assign({
    title: 'User/Login Successful!',
    message: 'User/Login Successful!',
    activeLink: undefined,
  }, await db.getDefaultRenderOptionsObj(req)));
});

Let's break that down:

router.post('/do2fa', async (req, res) => {
  if (!req.session.login || !req.session.login.username)
    res.redirect('/user/login');

This is a post route. If we haven't signified that the user has succeeded at the username/password login yet -- by setting their username in req.session.login.username -- then we kick them back to that login form.

  const secret = await db.getTOTPSecret(req.session.login.username);
  const valid2fa = mu.validateTOTP(req.body.twofactor, secret);

Above we fetch the user's TOTP secret from the database, and then validate their input with our wrapper function. My username is sanitized before storage in the session; be sure you are sanitizing it at some point before it finds its way to db.getTOTPSecret(), since that method puts the username in a database query.

  if (!valid2fa) {
    try {
      return res.render('user-2faform', Object.assign({
        title: '2-Factor Authentication Failed. Try again!',
        message: '2-Factor Authentication Failed. Try again!',
        activeLink: undefined,
      }, await db.getDefaultRenderOptionsObj(req)));
    } 
    catch (e) { logError(e); return }
  }

As shown above, if the 2FA 6-digit code wasn't valid, we kick the user back to the user-2faform.pug template with a message that they should try again. We return the call to res.render() (and return in the catch block for extra security) so that users with invalid logins, won't experience the code below:

  req.session.username = req.session.login.username;
  delete req.session.login;

Now we can log the user in. Remember when I talked about session variables that signify to your system that the user is logged in? We need to move those out of their temporary holding cell and into their normal configuration. And thus, the user is logged in.

  res.render('user-dologin', Object.assign({
    title: 'User/Login Successful!',
    message: 'User/Login Successful!',
    activeLink: undefined,
  }, await db.getDefaultRenderOptionsObj(req)));
});

Now we can serve the template that redirects users to a start page post-login. We do that simply by serving the same template that was originally served for successful username/password logins before 2FA was integrated.

Conclusion

Two-factor authentication only works if it's done correctly. My first implementation had a single site-wide secret that was shown on the login page. If I hadn't thought about the fact that this broadcasts the only layer of security involved in 2FA to everyone interested, it would have stayed that way. (With thanks to a friend who confirmed my suspicions that I had done it wrong, and shared what they knew.)

On that note, I should point out that I am not terribly well-versed in security matters. The information provided in this article comes as-is with no claims of usability for any purpose. If you follow this tutorial, I am not responsible for any results. Sorry, had to get a little bit of legal-ese out of my system.

Now the only thing left is to decide whether all logins require 2FA, or whether to make it optional? It depends on how disaster-proof your website is. The higher your risk after security has been penetrated, the more security you need. I went with mandatory 2FA for all logins because I don't like disaster recovery at all. I don't like disasters. At all. The minor speed bump of using two-factor authentication to log into my own website is nothing compared to the multi-day, sometimes weeks-long ordeal of recovering from a hack.

🔍

Valid HTML!Valid CSS!Powered by Node.js!Powered by Express.js!Powered by MongoDB!