HomeHow to create a simple HTTP API with authentication with Node.js?

How to create a simple HTTP API with authentication with Node.js?

By · Node.js & JavaScript developer
Published July 17, 2021 · Updated July 5, 2026

Almost every backend eventually needs the same three things: a way to sign users up, a way to log them in, and a way to protect routes so only authenticated users can reach them. In this tutorial we'll build all three from scratch with Node.js, Express, JSON Web Tokens (JWT) and bcrypt — no boilerplate framework, just the pieces you actually need to understand.

We'll use Express to handle routing, jsonwebtoken to issue and verify auto-expiring tokens, and bcrypt to hash passwords so we never store them in plain text. Create an empty folder and initialize the project. We'll name the main file index.mjs so we can use top-level await and native import/export.

npm init -y
npm i --save express jsonwebtoken bcrypt

Bootstrapping the Express server

Let's set up the Express router and start the server. We'll speak JSON everywhere and prefix every route with /api/.

import express from 'express';

const port = 8000;
const app = express();
const router = express.Router();

app.use(express.json());
app.use('/api/', router);

app.listen(port, () => {
  console.log(`Server started at port ${port}`);
});

Signup: hashing the password and issuing a token

bcrypt needs to know how many salt rounds to run, jsonwebtoken needs a secret to sign and verify tokens, and for our user store we'll just use a plain object to keep the focus on authentication. In a real app the secret becomes an environment variable and the users live in a database.

import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';

const rounds = 10;
const secret = process.env.JWT_SECRET || 'usethissecretfortokens';
const Users = {};

The token generation has many options, but the synchronous version is the simplest. We pass the user as the payload so that, once a token is verified, we know exactly who is making the request. Notice expiresIn — the token automatically becomes invalid after 24 hours.

const generateToken = (payload) => {
  return jwt.sign({ data: payload }, secret, { expiresIn: '24h' });
};

Now the signup route. We keep validation minimal — check the fields exist, make sure the email isn't already taken, hash the password, store the user and return a token. bcrypt hashing is intentionally CPU-expensive, so it runs asynchronously.

router.post('/signup', (req, res) => {
  const email = req.body.email;
  const pass = req.body.password;
  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }
  if (!pass) {
    return res.status(400).json({ error: 'Password is required' });
  }
  if (Users[email]) {
    return res.status(409).json({ error: 'User already exists' });
  }
  bcrypt.hash(pass, rounds, (err, password) => {
    if (err) {
      return res.status(500).json(err);
    }
    Users[email] = { email, password };
    res.status(200).json({ token: generateToken(Users[email]) });
  });
});

What it does is simply check that the fields exist, check that the email is unique (again, no real value validation — we're simplifying), then run the bcrypt hashing. We take the resulting hash and store it on the user object. When we generate the token we pass the whole user object, so when the token is later verified it carries that context. Send a JSON body with email and password to /api/signup and you'll get back a JSON document with a single token field.

Login: verifying the password

The login route looks up the user, compares the submitted password against the stored hash with bcrypt.compare, and issues a fresh token on success. We keep the "user not found" and "authentication failed" errors separate here for clarity, but in production you'd return the same generic message for both to avoid revealing which emails are registered.

router.post('/login', (req, res) => {
  const email = req.body.email;
  const password = req.body.password;
  const user = Users[email];
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  bcrypt.compare(password, user.password, function(err, match) {
    if (err || !match) {
      return res.status(401).json({ error: 'Authentication failed' });
    }
    res.status(200).json({ token: generateToken(user) });
  });
});

Protecting routes with token middleware

This is the important part. We'll write a middleware that reads the token from the Authorization header, verifies it, attaches the decoded user to req.user, and calls next(). Any route that uses it becomes token-guarded. Note that we respond with 401 rather than 500 when verification fails — a missing or expired token is a client problem, not a server error.

const verifyToken = (token, cb) => {
  if (!token) {
    return cb({ error: 'Authorization token is empty' }, null);
  }
  jwt.verify(token, secret, cb);
};

const tokenMiddleware = (req, res, next) => {
  const token = req.headers.authorization;
  verifyToken(token, (err, value) => {
    if (err) {
      return res.status(401).json({ error: 'Token verification failed' });
    }
    req.user = value.data;
    next();
  });
};

A common convention is to send the token as a Bearer token, e.g. Authorization: Bearer <token>. If you adopt that, strip the prefix before verifying with const token = (req.headers.authorization || '').replace(/^Bearer\s+/, '');. Now protecting any route is a one-liner — just drop the middleware in before the handler:

router.post('/posts/create', tokenMiddleware, (req, res) => {
  // req.user is available here thanks to the middleware
  res.status(200).json({ result: `Post created by ${req.user.email}` });
});

Testing the API with curl

You don't need Postman to try it out. Sign up to get a token, then call the protected route with it in the Authorization header:

# 1. sign up and get a token
curl -s -X POST http://localhost:8000/api/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"s3cret"}'

# 2. call the protected route with the returned token
curl -s -X POST http://localhost:8000/api/posts/create \
  -H "Authorization: <paste-token-here>"

The complete script

Here's everything put together in a single index.mjs:

import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';

const port = 8000;
const rounds = 10;
const secret = process.env.JWT_SECRET || 'usethissecretfortokens';
const app = express();
const router = express.Router();
const Users = {};

const generateToken = (payload) => {
  return jwt.sign({ data: payload }, secret, { expiresIn: '24h' });
};

const verifyToken = (token, cb) => {
  if (!token) {
    return cb({ error: 'Authorization token is empty' }, null);
  }
  jwt.verify(token, secret, cb);
};

const tokenMiddleware = (req, res, next) => {
  const token = req.headers.authorization;
  verifyToken(token, (err, value) => {
    if (err) {
      return res.status(401).json({ error: 'Token verification failed' });
    }
    req.user = value.data;
    next();
  });
};

router.post('/signup', (req, res) => {
  const email = req.body.email;
  const pass = req.body.password;
  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }
  if (!pass) {
    return res.status(400).json({ error: 'Password is required' });
  }
  if (Users[email]) {
    return res.status(409).json({ error: 'User already exists' });
  }
  bcrypt.hash(pass, rounds, (err, password) => {
    if (err) {
      return res.status(500).json(err);
    }
    Users[email] = { email, password };
    res.status(200).json({ token: generateToken(Users[email]) });
  });
});

router.post('/login', (req, res) => {
  const email = req.body.email;
  const password = req.body.password;
  const user = Users[email];
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  bcrypt.compare(password, user.password, function(err, match) {
    if (err || !match) {
      return res.status(401).json({ error: 'Authentication failed' });
    }
    res.status(200).json({ token: generateToken(user) });
  });
});

router.post('/posts/create', tokenMiddleware, (req, res) => {
  // req.user is available here thanks to the middleware
  res.status(200).json({ result: `Post created by ${req.user.email}` });
});

app.use(express.json());
app.use('/api/', router);

app.listen(port, () => {
  console.log(`Server started at port ${port}`);
});

Where to go from here

This is a complete, working auth flow, but a few things separate it from something you'd ship to production:

  1. Move the JWT secret into an environment variable (e.g. a .env file read via process.env.JWT_SECRET) and never commit it to source control.
  2. Replace the in-memory Users object with a real database such as PostgreSQL or MongoDB.
  3. Add refresh tokens: issue a short-lived access token (minutes) alongside a long-lived refresh token and expose a /refresh endpoint, so users don't have to log in constantly.
  4. Serve the API over HTTPS so tokens can't be sniffed in transit.
  5. Rate-limit /signup and /login to slow down brute-force attempts.
  6. Return generic authentication errors in production to avoid leaking which emails are registered.

With signup, login and token-protected routes in place, you have the backbone of almost any authenticated API — the rest is just building your real routes behind that same tokenMiddleware.

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.