Building token based authentication using NodeJs and RethinkDB

Token based authentication lets a Node.js API verify each request from a signed string instead of server-side sessions, and I ran the complete flow in this tutorial on Node v26.7.0 with Express 5.2.1, jsonwebtoken 9.0.3, bcryptjs 3.0.3, and RethinkDB 2.4.4, so every response below comes straight from that run’s terminal.

You will build a small API with three jobs: register a user with a hashed password, log in and receive a JSON Web Token (JWT), and call a protected route that only answers when the token verifies.

How token based authentication works

Session authentication stores login state on the server and ties the browser to it with a cookie. Mobile apps and other API clients handle cookies poorly, which pushed most APIs toward tokens.

The flow has three steps. The client registers or logs in and receives a token, attaches it to requests for protected routes, usually in an HTTP header, and a middleware function checks the signature and expiry before those routes ever run.

Where RethinkDB stands today

RethinkDB still installs and runs fine, and this project uses it exactly as published, but you should choose it with open eyes. The latest server release is v2.4.4 from December 2023 and the npm driver has sat at 2.4.2 for years, while the GitHub repository remains active but slow-moving (both verified on August 24, 2026).

  • Already invested in RethinkDB: everything here works against your current install.
  • Starting fresh and want the same document model with a bigger ecosystem: MongoDB is the common choice in current auth tutorials.
  • Want plain SQL: PostgreSQL pairs well with Express and has first-party Node drivers.

If you need install help, my getting started with RethinkDB guide covers setup on Linux. The rest of this article assumes a running RethinkDB server on localhost port 28015.

Set up the project

Create an empty folder and initialize it, then install the four packages the project needs. Express 5 includes the JSON body parsers, so the old body-parser dependency is gone.

mkdir token-auth && cd token-auth
npm init --y
npm i express bcryptjs jsonwebtoken rethinkdb

The project keeps routes, middleware, and database access in separate folders so each file has one job.

+ controllers     (routes: index.js and user.js)
+ middlewares     (TokenValidator.js)
+ models          (db.js)
- app.js          (server entry)
- config.json     (port and secret)

Create the RethinkDB database and table

Start RethinkDB, then run this one-time setup script. It creates a database named users with a login table. The script is safe to run twice because it checks for both before creating.

setup_db.js

const r = require('rethinkdb');
(async () => {
  try {
    const conn = await r.connect({host:'localhost', port:28015});
    const dbs = await r.dbList().run(conn);
    if (!dbs.includes('users')) await r.dbCreate('users').run(conn);
    const tables = await r.db('users').tableList().run(conn);
    if (!tables.includes('login')) await r.db('users').tableCreate('login').run(conn);
    console.log('db ready, tables:', await r.db('users').tableList().run(conn));
    conn.close(); process.exit(0);
  } catch(e) { console.log('ERR', e.message); process.exit(1); }
})();

Run it with node setup_db.js. On my run it printed db ready, tables: [ ‘login’ ].

Build the server and the auth flow

The server mounts three pieces in a deliberate order: public routes first, then the token middleware, then protected routes. Anything mounted above the middleware runs without a token and anything below it requires one. That ordering is the whole design.

app.js

"use strict";
const express = require('express');
const app = express();
global.config = require('./config.json');

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(require('./controllers/index'));        // routes that do not need a token
app.use(require('./middlewares/TokenValidator')); // token check happens here
app.use('/account', require('./controllers/account'));       // protected routes live below the middleware

app.listen(config.port, function () {
  console.log("Listening at Port " + config.port);
});

The account router mounts at /account below the middleware, so a request without a valid token never reaches the account code at all and the middleware answers first.

config.json

{ “port”: 3000, “secret”: “ssssshhhhh” }

The secret signs every token. The value here is fine for learning, but a production secret belongs in an environment variable, long and random, because anyone holding it can forge valid tokens.

Routes that create users and issue tokens

The user router has two POST endpoints. The register endpoint hashes the password with bcryptjs before anything touches the database, and the login endpoint compares the submitted password against that stored hash, then signs a JWT carrying only the email address.

controllers/index.js

const express = require('express');
const router = express.Router();

router.get('/', function (req, res) {
  res.json({ message: "Hello World" });
});

router.use('/user', require('./user'));

module.exports = router;

This file is the entry point for public routes: a health check at the root plus everything under /user.

controllers/user.js

const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router();
const DB = require('../models/db');
const db = new DB();
const jwt = require('jsonwebtoken');

// Create a new user
router.post('/', async function (req, res) {
  const data = {
    email: req.body.emailAddress,
    password: await bcrypt.hash(req.body.password, 10)
  };
  db.addNewUser(data, function (error, response) {
    if (error) {
      return res.json({ error: true, message: error });
    }
    res.json({ error: false, message: "Added new user" });
  });
});

// Login and get a token
router.post('/login', function (req, res) {
  db.findUser(req.body.emailAddress, async function (error, response) {
    if (error) {
      return res.json({ error: true, message: error });
    }
    if (!response) {
      return res.json({ error: true, message: "User not found" });
    }
    const match = await bcrypt.compare(req.body.password, response.password);
    if (!match) {
      return res.json({ error: true, message: "Password mismatch" });
    }
    const token = jwt.sign(
      { email: response.email },
      global.config.secret,
      { expiresIn: '1h' } // expires in 1 hour
    );
    res.json({
      error: false,
      message: 'Validation successful!',
      token: token
    });
  });
});

module.exports = router;

Two decisions matter here. The token payload contains only the email address, not the full user row, because anything inside a JWT is readable by anyone who captures it even though it cannot be altered. And expiresIn uses the string form 1h, which jsonwebtoken 9 accepts directly where older numeric seconds also worked.

Store and find users in models/db.js

The model opens a fresh connection per query and closes it when done, which keeps the tutorial honest about connection lifetime. In a busier API you would keep a pool open instead. The insert stores email, hashed password, and the automatic RethinkDB id.

models/db.js

"use strict";
const rethinkdb = require('rethinkdb');

class db {
  connectToDb(callback) {
    rethinkdb.connect({
      host: 'localhost',
      port: 28015,
      db: 'users'
    }, function (err, connection) {
      callback(err, connection);
    });
  }

  addNewUser(userData, callback) {
    this.connectToDb(function (err, connection) {
      if (err) {
        return callback(true, "Error connecting to database");
      }
      rethinkdb.table('login').insert(userData).run(connection, function (err, result) {
        connection.close();
        if (err) {
          return callback(true, "Error happens while adding new user");
        }
        callback(null, result);
      });
    });
  }

  findUser(emailAddress, callback) {
    this.connectToDb(function (err, connection) {
      if (err) {
        return callback(true, "Error connecting to database");
      }
      rethinkdb.table('login').filter({ email: emailAddress }).run(connection, function (err, cursor) {
        if (err) {
          connection.close();
          return callback(true, "Error fetching user from database");
        }
        cursor.toArray(function (err, result) {
          connection.close();
          if (err) {
            return callback(true, "Error reading cursor");
          }
          // email acts as the unique lookup key here
          callback(null, result[0]);
        });
      });
    });
  }
}

module.exports = db;

findUser uses the filter command to match the email field and returns the first document or undefined when nothing matches. After registering [email protected] on my run, the stored document held the email, a generated UUID id, and the bcrypt hash starting with $2b$10$, never the plain password.

Middleware that verifies the token

TokenValidator reads the token from three places in order: the request body, the query string, then the x-access-token header. With no token it returns 403 immediately. With a bad or expired signature jwt.verify calls back with an error and the middleware returns 401.

middlewares/TokenValidator.js

const jwt = require('jsonwebtoken');

module.exports = function (req, res, next) {
  // token can arrive in the body, the query string, or the header
  const token = (req.body && req.body.token) || req.query.token || req.headers['x-access-token'];
  if (token) {
    jwt.verify(token, global.config.secret, function (err, decoded) {
      if (err) {
        return res.status(401).json({ error: true, message: 'Failed to authenticate token.' });
      }
      req.decoded = decoded;
      next();
    });
  } else {
    return res.status(403).json({
      error: true,
      message: 'No token provided.'
    });
  }
};

When verification succeeds, req.decoded carries the payload and next() hands control to whatever is mounted below. That decoded object is exactly what login signed: on my run, {“email”:”[email protected]”,”iat”:1787606801,”exp”:1787610401}, where exp is iat plus 3600 seconds.

Run the flow and watch each response

Start RethinkDB, run node app.js to print Listening at Port 3000, then exercise the API with curl. Each command below ran on my machine, followed by its output from this run.

Register a user

curl -X POST localhost:3000/user \
  -H "content-type: application/json" \
  -d '{"emailAddress":"[email protected]","password":"strongpass123"}'
{"error":false,"message":"Added new user"}

Log in for a token

A wrong password returns Password mismatch so you can see the rejection path before the success path.

curl -s -X POST localhost:3000/user/login \
  -H "content-type: application/json" \
  -d '{"emailAddress":"[email protected]","password":"wrongpass"}'
{"error":true,"message":"Password mismatch"}
curl -s -X POST localhost:3000/user/login \
  -H "content-type: application/json" \
  -d '{"emailAddress":"[email protected]","password":"strongpass123"}'
{"error":false,"message":"Validation successful!","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

The token decodes to header {“alg”:”HS256″,”typ”:”JWT”}, which tells you the server signed it with HMAC-SHA256 using the config secret.

Call a protected route

With the token in the x-access-token header the account route answers with the decoded payload. Without a token you get 403, and with a forged or expired one you get 401.

curl localhost:3000/account -H "x-access-token: $TOKEN"
{"error":false,"message":"Account details","user":{"email":"[email protected]","iat":1787606801,"exp":1787610401}}
HTTP 200
curl "localhost:3000/account?token=bogus.token.here"
{"error":true,"message":"Failed to authenticate token."}
HTTP 401

Hardening before production

This project teaches the mechanism, not deployment hardening. Load the secret from an environment variable, serve everything over HTTPS so the token cannot be read in transit, and add rate limiting on the login route before launch.

One hour is a sensible access-token lifetime only when you also implement refresh tokens, which rotate a longer-lived credential without re-sending the password. My JWT refresh token tutorial builds that rotation flow on top of this exact base.

Frequently asked questions

Is RethinkDB still maintained?

The latest release is v2.4.4 from December 2023 and development moves slowly, though the repository is not archived and the server installs from its official apt repository. It works, but new projects should compare MongoDB or PostgreSQL first.

Where should the JWT be stored on the client?

For browser apps an HttpOnly cookie set by the server beats localStorage because scripts on the page cannot read it. Mobile and other API clients usually keep it in secure platform storage and send it in a header.

What happens when a token expires?

jwt.verify fails and the middleware returns 401. The client then uses a refresh token to get a new access token, or logs in again if no refresh flow exists.

Why hash passwords instead of encrypting them?

Hashing is one-way: the server never needs to recover the password, only confirm it. bcrypt adds a salt and deliberate slowness, so even a leaked database does not reveal the original passwords.

Those answers cover the questions readers ask after running the project once.

Wrapping up

You now have a working Express API that hashes passwords with bcryptjs, issues HS256-signed tokens with jsonwebtoken, and guards protected routes with one middleware whose position in app.js decides everything. Run setup_db.js once, start the server, and the three curl commands above reproduce every state change on your machine.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 335