Learning web development: Authenticating users with plain Node.js

[2025-09-15] dev, javascript, learning web dev
(Ad, please don’t block)

This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.

To download the projects, go to the GitHub repository learning-web-dev-code and follow the instructions there.

I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.


In this chapter, we learn how to write a server that lets users log in via passwords. That process is called authentication.

Project: basic-http-authentication/  

This project is a web server that protects the pages it serves via passwords. From now on, when you see a path, it’s always relative to the directory basic-http-authentication/.

New JavaScript features  

Parameter default values  

A parameter default value is an assignment we make to a parameter. It is performed whenever the parameter is missing:

function add(x = 0, y = 0) {
  return x + y;
}
assert.equal(
  add(3, 4), 7
);
assert.equal(
  add(3), 3 // default for `y` is used
);
assert.equal(
  add(), 0 // defaults for `x` and `y` are used
);

Dynamic import()  

Normal import statements are called static imports:

  • They can only exist at the top level of a module.
  • Their module specifiers are fixed strings.

To make importing more flexible, JavaScript also lets us dynamically import modules via import() (an operator that is used like a function). It works like a namespace import and returns a Promise for an object whose properties are the exports of the imported module:

> const {getHashForText} = await import('./server/crypto-tools.js');
> await getHashForText('abc')
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'

crypto-tools.js is a utility library in our current project. We use object destructuring to access its export getHashForText. It’s an asynchronous function – which is why we use await to retrieve its result.

Note that we also await the result of import() and that its argument can be any string; it’s not fixed.

Base64: encoding binary data as strings  

Base64 is a way to encode binary data as text. That enables us to store binary data in formats that only support text (such as HTML or JSON).

The 64 in “Base64” means that it uses 64 “printable” (visible) characters in the ASCII character range (Unicode code points 0–127). Therefore, one text character encodes 6 bits (64 is 2^6^).

Let’s use the Node.js REPL to explore how Base64 works:

> const {toBase64, fromBase64} = await import('./server/crypto-tools.js');
> toBase64(Uint8Array.of(0, 1, 2))
'AAEC'
> fromBase64('AAEC')
Uint8Array.of(0, 1, 2)

First, we import functions from module crypto-tools.js that let us convert to and from Base64. Uint8Array is a so-called typed array for bytes (unsigned 8-bit integers). Think of it as an array for binary data. We convert binary data to a Base64 string via toBase64(). Then we go the other direction via fromBase64().

Basic HTTP authentication  

There are many good authentication libraries available via npm. However, almost all of them are meant to be used with a backend framework such as Express. In this chapter, we use a very simple authentication method: Basic HTTP authentication. It has pros and cons:

  • Pro: It’s easy to add to a server that we implement ourselves in plain Node.js (without a backend framework).
  • Con: It sends passwords over the network without encryption. If we use HTTP then many people can see the passwords: Everyone on the same Wi-Fi network, internet providers, etc. However, with HTTPS, the traffic between the client and the server is encrypted and no one can see the authentication data. Therefore, web apps should only use Basic HTTP authentication if they are served over HTTPS. However, using HTTP with localhost during development is not a problem because all traffic stays local.

The Authorization header field  

With Basic HTTP authentication, the browser sends the following header field to authenticate a user when requesting a given web page:

Authorization: Basic VXNlcjpQYXNzd29yZA==

The text at the end contains user name and password, encoded in Base64 (we treat a string as if it were binary data):

> const {fromBase64} = await import('./server/crypto-tools.js');
> new TextDecoder().decode(fromBase64('VXNlcjpQYXNzd29yZA=='))
'User:Password'

We use class TextDecoder to convert a series of bytes in UTF-8 format to a JavaScript string.

This kind of encoding is so simple that it does not count as encryption and is certainly not safe. It only prevents humans from accidentally seeing a password.

The authorization process  

Normally, the browser does not add the Authorization header field to requests. If it requests a protected page without it then the server sends the following response (without content):

  • Status code: 401 Unauthorized
  • Header field: WWW-Authenticate: Basic realm="..."

The name of the realm identifies groups of pages on a server that have the same users and passwords. The 401 response causes the browser to ask the user for a user name and a password. The browser then resends its previous request but adds the Authorization header field this time. When receiving that request, the browser checks user name and password: If correct, it serves the web page. Otherwise, it serves a 401 response again.

After a successful authorization, most browsers store user name and password and use them for all pages in the same directory or below. That saves users from constantly having to enter user name and password. Therefore, if a user logs into:

https://example.com/protected/index.html

Then the browser automatically logs them into:

https://example.com/protected/help.html
https://example.com/protected/docs/info.html

Logging out  

One interesting challenge with Basic HTTP authentication is that we can’t really log out because we can’t tell the browser to forget a given user: It will simply keep sending the authentication data. Therefore, we need to resort to a trick: We make a request via fetch() and use incorrect authentication data. As a consequence, the browser replaces the previous (correct) data with the new (incorrect) data. The server rejects the latter and the browser asks the user to log in again.

This is what that looks like in site/index.html:

document.querySelector('a').addEventListener(
  'click',
  async (event) => {
    event.preventDefault();
    await fetch(
      '/index.html',
      {
        headers: {
          'Authorization': 'Basic ' + btoa('logout:logout'),
        },
      }
    );
    // Reload page so that new user data is displayed
    location.reload();
  }
);

Note that we do all of the above in the client. The server cannot log out a user.

Sources of this section:

How to store passwords safely  

How would we store passwords in a file or a database? Maybe in a file passwords.json that looks like this?

{
  "Kane": "Rosebud",
  "Wagstaff": "Swordfish"
}

User “Kane” has the password “Rosebud” etc. There is only one problem with doing this: Should someone get their hands on passwords.json they have free access to all the accounts protected by the passwords stored in it. Let’s see how we can prevent that.

Step 1: computing a hash  

Our initial idea is: Let’s not store the password, let’s store a hash of the password. Roughly, a hash is derived from input data and should by a good (often shorter) representation of it:

  • If two hashes are different, their input datas are certainly different.
  • If two hashes are equal, it should be as unlikely as possible that their input datas are not equal.

Hashes are often used in programming to detect if a long file has changed or to compute IDs for pieces of data.

We can also use hashes to encode passwords. The idea is:

  • We store user names and hashes of passwords somewhere.
  • If a user logs in with a user name and a password, we hash the latter and compare it with what we have stored.

A key requirement when hashing passwords is that going from input to hash is relatively quick but going from hash to input is very difficult. The longer the output, the longer it must take to do that. Otherwise, someone could easily convert hashes back to passwords and we wouldn’t have gained anything. Interestingly, as computers have become faster, password hashes have become longer so that attacks keep being difficult.

Additionally, the lengths of the hashes must be the same for all passwords so that attackers can’t gain any knowledge by looking at the lengths.

We can experiment with hashing in the Node.js REPL:

> const { getHashForText } = await import('./server/crypto-tools.js');
> await getHashForText('Rosebud')
'1727f9eedb5128f0cdf892ad31eac287ea16e261fd7ff9007037807c3ebc02dc'
> await getHashForText('Rosebud')
'1727f9eedb5128f0cdf892ad31eac287ea16e261fd7ff9007037807c3ebc02dc'

As we can see, there is no random element to getHashForText(): If the argument is the same, it produces the same output. It computes its result asynchronously, which is why we must await what it returns.

Step 2: adding salt  

Alas, hashing passwords still isn’t safe enough:

  • If someone has our passwords.json, they can use a table that contains popular passwords and their hashes. That will given them access to many accounts.
  • Additionally, if multiple users have the same password, that can be found out by looking at the hashes in passwords.json. And we don’t want anyone to know that.

Thankfully, there is a way to fix that: Every time a user sets a new password, we create a random string and use that as salt. Salt is simply another parameter for hashing: Using it is mostly equivalent to appending the salt to the password before hashing it. If we want to check if a given password is correct, we need the salt so that we can replicate the previous hashing. That’s why we keep both salt and hashed password in passwords.json.

The random factor of the salt makes it virtually impossible to use a table for well-known passwords because, per password, such a table would have to record the hashes for all possible salts. Additionally, thanks to the salts, two users with the same password will virtually never have the same hash.

This is how server/password-tools.js stores passwords in a Map (which is eventually saved to passwords.json):

import { generateSalt, hash, toBase64 } from './crypto-tools.js';
// ...
export async function setPassword(passwordsMap, user, password) {
  const saltBin = generateSalt();
  const hashedPasswordBin = await hash(password, saltBin);
  passwordsMap.set(
    user,
    {
      salt: toBase64(saltBin),
      hashedPassword: toBase64(hashedPasswordBin),
    }
  );
}

saltBin and hashedPasswordBin are binary data (instances of Uint8Array) – which is why we use toBase64() to convert them to strings so that we can easily store them as JSON. hash() is an async function.

The new passwords.json  

This is what our passwords.json looks like now:

{
  "Kane": {
    "salt": "...",
    "hashedPassword": "..."
  },
  "Wagstaff": {
    "salt": "...",
    "hashedPassword": "..."
  }
}

Comparing a password with its hash  

This is how server/password-tools.js checks if a password is valid:

import { verify, fromBase64 } from './crypto-tools.js';
// ...
export async function isValidPassword(passwordsMap, user, password) {
  const entry = passwordsMap.get(user);
  if (entry === undefined) {
    return false;
  }
  const { salt, hashedPassword } = entry;
  const saltBin = fromBase64(salt);
  const hashedPasswordBin = fromBase64(hashedPassword);

  return await verify(password, hashedPasswordBin, saltBin);
}

It redoes the hashing and compares the result with the stored hash via the async function verify(). We can’t use the strict equality operator === to do that because it finishes quickly if its operands are not equal. That gives attackers valuable information. Therefore, verify() always takes the same length of time.

Why are most of the functions in crypto-tools.js async?  

There are two reasons for why most of the cryptography functionality in crypto-tools.js is asynchronous:

  • The computations can take long and it may be possible to perform them in threads other than the main thread. By making them asynchronous, they then don’t block the main thread.
  • The computations being asynchronous also makes it possible to use more than one thread to compute them – which speeds up those computations because more than one processor core can be used.

Project basic-http-authentication/  

How we manage passwords in this project  

In professional web sites, managing passwords is quite involved – e.g., when you register a new user, you provide an email address and the server sends an email to that address in order to confirm that it exists and that the user has access to its emails.

For our hobby project, we take a simpler approach: We manage a JSON file with passwords via a shell command. The server uses that JSON file to authenticate. We add new users manually. As an exercise, we let users change their passwords via the web interface.

File structure  

This is what the top level of this project looks like in the file system:

  • package.json
  • site/: the files served by the server
    • index.html: the client side of the project
  • data/
    • passwords.json: the passwords
  • server/: contains the code for a server that protects all pages it serves via Basic HTTP authentication. When it starts, it reads its passwords from data/passwords.json.
  • cli/
    • passman.js: shell command for adding, changing and deleting passwords

We only need package.json for one dependency of the server. This time, the client side of our project has no npm dependencies and therefore does not need a build step. It’s simply the file site/index.html

package.json  

This is what the package.json of this project looks like:

{
  "type": "module",
  "scripts": {
    "start": "node --watch server/server.js",
    "test": "node --test \"server/**/*_test.js\""
  },
  "dependencies": {
    "http-auth": "^4.2.1"
  }
}

Package http-auth is a dependency of the server – which helps it with authentication. There are two package scripts:

  • start lets us run the server via npm start.
  • test runs the tests in directory server/.

server/password-tools.js  

We have already seen the following two functions:

  • setPassword(passwordsMap, user, password)
  • isValidPassword(passwordsMap, user, password)

Module password-tools.js additionally lets us read a passwords Map from a JSON file and write it to one. The following code shows the latter:

// Sibling of parent directory of this module
const DATA_DIR = new URL('../data/', import.meta.url);
if (!existsSync(DATA_DIR)) {
  throw new Error('Could not find data directory: ' + DATA_DIR);
}
const PASSWORDS_FILE = new URL('passwords.json', DATA_DIR);

export const readPasswordsMap = async (fileUrl = PASSWORDS_FILE) => {
  if (existsSync(fileUrl)) {
    const json = await fs.readFile(fileUrl, 'utf-8');
    const passwordsObject = JSON.parse(json);
    return new Map(Object.entries(passwordsObject)); // (A)
  } else {
    return new Map();
  }
};

In line A, we use Object.entries() to convert the plain object from the JSON data to a Map. If we omit the parameter of readPasswordsMap() then the parameter default value kicks in and the passwords are read from PASSWORDS_FILE.

cli/passman.js  

cli/passman.js is the shell command for managing passwords. If we invoke it without arguments, we get the following help text:

Subcommands:
passman.js set <passwords.json> <user> <password>
  Adds or changes a given password.
  Creates the passwords file if it doesn’t exist yet.
passman.js rm <passwords.json> <user>
  Removes a password entry.
passman.js check <passwords.json> <user> <password>
  Checks if the given password is correct.
passman.js ls <passwords.json>
  Lists all users in the passwords file.

This is an example of using it:

% node cli/passman.js set data/passwords.json Kane Rosebud
% node cli/passman.js check data/passwords.json Kane Rosebud
true
% node cli/passman.js check data/passwords.json Kane WRONG  
false

Since passwords.json doesn’t exist yet, the subcommand set creates it.

server/string-tools.js  

Mode string-tools.js exports a single helper function:

export function insertVars(vars, string) {
  for (const [key, value] of Object.entries(vars)) {
    string = string.replaceAll('{{' + key + '}}', value);
  }
  return string;
}

vars is an object that maps variable names to strings. insertVars() inserts those strings into its parameter string and returns it. To insert a variable, we put its name in double braces ({{varName}}). string-tools_test.js demonstrates how that works:

import * as assert from 'node:assert/strict';
import { test } from 'node:test';
import { insertVars } from './string-tools.js';

test('insertVars()', () => {
  assert.equal(
    insertVars({ user: 'Robin' }, 'Hello {{user}}!'),
    'Hello Robin!'
  );
  assert.equal(
    insertVars({}, 'Hello {{user}}!'),
    'Hello {{user}}!'
  );
});

server/server.js  

This is our authenticating server:

import auth from 'http-auth';
import * as http from 'node:http';
import { handleFileRequest } from './handle-file-request.js';
import { isValidPassword, readPasswordsMap } from './password-tools.js';

const hostname = 'localhost';
const port = 3000;
const passwordsMap = await readPasswordsMap();

const basic = auth.basic( // (A)
  { realm: 'Users' },
  async (user, password, callback) => {
    const isValid = await isValidPassword(passwordsMap, user, password);
    callback(isValid);
  }
);

http
  .createServer(
    basic.check( // (B)
      async (request, response) => { // (C)
        await handleFileRequest(
          request, response,
          { user: request.user }
        );
      }
    )
  )
  .listen(port, hostname, () => {
    console.log(`Server running at http://${hostname}:${port}/`);
  })
  ;

In line A, we set up the basic authentication object basic. The function we pass to the method that creates that object checks if a given password is correct and passes the result to a callback. That is an alternative to Promises when it comes to handling asynchronous results.

In line B, we use method basic.check() to create a callback for createServer(). That callback only invokes the function (line C) it wraps if authentication succeeds.

server/handle-file-request.js  

handle-file-request.js is nearly identical to the same module from the previous chapter, but there is one difference: It has the additional parameter vars. If it serves and HTML file, it inserts those vars before serving it:

if (contentType === 'text/html') {
  let content = await fs.readFile(fileUrl, 'utf-8');
  content = insertVars(vars, content);
  response.end(content);
  return;
}

site/index.html  

The client side of our project looks like this:

<h1>Authenticated: {{user}}</h1>
<p><a href="">Log out</a></p>
<script type="module">
  document.querySelector('a').addEventListener(
    'click',
    // ...
  );
</script>

We have already seen the code for logging out, which is why it is omitted here. Note the variable {{user}} in the first line.

Exercises (without solutions)  

The following two exercises are challenging but should teach you a lot:

  • todo-list-server/: Add the functionality of project basic-http-authentication so that each user has their own todo list.
    • Both file requests and API requests must be authenticated! Due due /api being inside /, the browser automatically authenticates.
  • basic-http-authentication/: Provide a user interface for changing the password.
    • The user must be logged in and provide their old password and a new password.
    • Use API requests for making the changes. For inspiration, see how API requests are handled in project todo-list-server