How to create a simple HTTP API with authentication with Node.js?
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 bcryptBootstrapping 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:
- Move the JWT secret into an environment variable (e.g. a
.envfile read viaprocess.env.JWT_SECRET) and never commit it to source control. - Replace the in-memory
Usersobject with a real database such as PostgreSQL or MongoDB. - Add refresh tokens: issue a short-lived access token (minutes) alongside a long-lived refresh token and expose a
/refreshendpoint, so users don't have to log in constantly. - Serve the API over HTTPS so tokens can't be sniffed in transit.
- Rate-limit
/signupand/loginto slow down brute-force attempts. - 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.