How to implement JWT authentication in a Node.js API

  • Authentication with JWT enables stateless, scalable, and easy-to-maintain APIs in Node.js.
  • Node.js, Express, MongoDB, jsonwebtoken and bcryptjs form the technical basis of a secure API.
  • The flow is based on registering users, issuing tokens after login, and protecting routes with middleware.
  • Proper management of secrecy, token expiration, and password encryption is key to security.

JWT authentication in a Node.js API

When we develop an API today, the security and access control They're no longer an extra; they're a requirement. Any remotely serious application needs to distinguish which users can log in, what they can do, and for how long. This is where authentication based on... JSON Web Tokens (JWTs), a system widely used because of how easy it is to integrate and how well it scales.

In this article you will see, step by step and in great detail, How to implement JWT authentication in an API with Node.js and ExpressWe'll see exactly what a JWT is, why it's so widely used in modern APIs, what libraries you need, and how to build a complete flow for registration, login, and route protection from scratch. We'll also discuss best practices and helpful tips to ensure your implementation is secure.

What exactly is a JSON Web Token (JWT)?

A JWT is, in short, a standard format for sending information as JSON in a compact and digitally signed formIt is defined in the RFC 7519 specification and was designed to be easy to transport over the network, for example in HTTP headers or even in the URL.

The beauty of JWTs is that the server can trust the data contained in the token because it is signedThis signature is generated using a shared secret (in HMAC schemes) or a public/private key pair (algorithms like RSA or ECDSA). As long as the server can verify the signature, it knows that the token's contents have not been tampered with.

On a practical level, a JWT consists of three parts encoded in Base64 and separated by periods: header, payload, and signature. The header indicates, among other things, the signature algorithm. The payload is where you put the information you're interested in (for example, the user ID), and the signature guarantees the integrity of the entire set.

CI/CD workflow with GitHub Actions
Related article:
How to set up a CI/CD flow with GitHub Actions from scratch

Advantages of using JWT to authenticate in an API

The reason we see JWT everywhere is that it enables a model of stateless authenticationThis means the server doesn't have to store sessions in memory or in a database: it simply validates the token on each request and, if it's correct, grants access.

This stateless approach makes it much easier horizontal scaling of your APIIf you have multiple servers behind a load balancer, you don't need to share session information between them. As long as they all know the same secret key to validate tokens, any of them can respond to any request consistently.

Furthermore, the JWTs are very compact and easy to transportThey can be sent in the Authorization header, in a cookie, or, if necessary, even in query parameters. This makes them especially convenient for Single Page Applications (SPAs) or mobile clients that consume your API.

Another very important advantage is that you can include relevant user information in the token (roles, permissions, IDs, etc.) to make decisions without having to perform an additional database query on each request. However, it's advisable not to overuse this feature and avoid including sensitive data or more information than necessary.

How the authentication flow works with JWT

The typical process for integrating JWT into a Node.js API follows a fairly clear sequence that should be understood well before touching any code. In simplified terms, the token lifecycle in an app It is usually the following:

  1. The user submits their credentials (username and password, usually) to the server through a login endpoint.
  2. The server verifies those credentials. against the database (or other storage system). If they do not match, the request is rejected with an appropriate error.
  3. If everything adds up, the server Generates a JWT with essential user information (for example, your ID) and signs it with the secret key or the private key configured for that service.
  4. That token is returned to the client, who can store it in localStorage, sessionStorage or in a cookiedepending on the security strategy you want to follow.
  5. From that moment on, in every request for protected resources, the client sends the JWT to the server, usually in the HTTP Authorization header with the format: Authorization: Bearer <token>.
  6. The server receives the token and Validate by verifying their signature and, if applicable, their expiration dateIf the token is correct, the request is processed; otherwise, a 401 or 403 is returned.
  7. When the token expires or becomes invalid, the server rejects the requests and the client must re-authenticate to obtain a new token.

This flow has the enormous advantage that, once the token is issued, the server no longer cares about the session statusprovided that the duration and content of the JWT are trusted.

Prerequisites for creating a JWT API with Node.js

Before we delve into the API itself, it's important to be clear about what tools and dependencies You will need these in your development environment. The minimum list to follow for this type of implementation is usually:

  • Node.js installed on your machine, since it will be the execution environment for the entire backend.
  • Express, the Node.js framework that will make it easier for you to create routes and middleware.
  • Postman or Insomnia (or any other HTTP client) to test the API endpoints without needing a frontend yet.
  • MongoDB as a NoSQL database, along with Mongoose to define models and schemas, in case you want to persist users easily.
  • Specific packages for authentication, such as jsonwebtoken to issue and validate tokens, bcryptjs to encrypt passwords, and dotenv to handle environment variables.

With this set of tools you'll have a more than decent environment to set up a REST API with JWT authentication in Node.js and be able to extend it without too many complications.

Creation and initialization of the Node.js project

The first technical step will be to create the working directory and generate the package.json file where your project's dependencies will be registered. From the terminal, you could start with something very similar to the following:

mkdir jwt-auth-api
cd jwt-auth-api
npm init -y

Once the project has been initialized, it's time to... install the main dependencies for our API. A typical combination might be:

npm install express mongoose jsonwebtoken bcryptjs dotenv

This adds to your project the Express framework, the Mongoose ODM for MongoDB, the jsonwebtoken library which will handle signing and verifying tokens, and bcryptjs for Encrypt passwords securely and dotenv to manage sensitive variables in an environment file.

Basic file structure for the API

JWT authentication in a Node.js API

A very simple (but functional) organization of your project might look like this, so that the main file and the data models are well separated:

jwt-auth-api/
.env
app.js
models/
User.js

The file .env This will be used to store your token's signing secret key and any other settings you don't want to upload to the repository. Inside that file, you can put something like:

JWT_SECRET=un_secreto_muy_seguro_y_largo

The idea is that Never expose this key in the source code directly, especially if you're going to upload the project to remote version control. dotenv will take care of loading these variables and making them available to you in process.env.

Defining the user model with Mongoose

To register users in the database, you need a model that defines what fields will each user document haveA typical example in models/User.js It could be something like this:

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const UserSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
password: { type: String, required: true }
});

UserSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});

module.exports = mongoose.model('User', UserSchema);

In this scheme, a pre-save operation (hook pre('save')) It detects if the password field has changed. If so, it encrypts it using bcrypt with a specified number of hash rounds (10 in the example). This prevents you from storing passwords in plain text, which is completely unacceptable in any production environment.

Then you simply export the User model so you can use it in the main file and anywhere else in the application where you need it. This pattern allows you to maintain encapsulated data logic in the models and not mix it with the routes.

Express server configuration and connection to MongoDB

The core of your API will be in app.jsThis is where you'll set up the HTTP server, connect to the database, and define the key authentication endpoints. A very common skeleton might look like this, with some corrections to the messy pseudocode you saw:

const express = require('express');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const dotenv = require('dotenv');
const User = require('./models/User');

dotenv.config();

const app = express();
app.use(express.json());

mongoose.connect('mongodb://localhost/jwt-auth', {
useNewUrlParser: true,
useUnifiedTopology: true
});

With this initial configuration you are loading all the necessary departmentsThis involves enabling JSON support in the request body and establishing the connection with MongoDB using the default local URL. In a real-world environment, this URL could also be derived from an environment variable to separate development, testing, and production.

New user registration endpoint

The next block of functionality will be to allow a user to Register in the system by creating a new document in the user collection. A typical registration endpoint might look like this:

Dark mode on a website with CSS variables
Related article:
Complete guide to arrow and this functions in JavaScript

app.post('/register', async (req, res) => {
try {
const { username, password } = req.body;

const existingUser = await User.findOne({ username });
if (existingUser) {
return res.status(400).json({ message: 'The user already exists' });
}

const user = new User({ username, password });
await user.save();

res.status(201).json({ id: user._id, username: user.username });
} catch (error) {
res.status(500).json({ message: 'Error registering user' });
}
});

Here, the username and password are extracted from the request body, it's checked if the username is already taken, and if it's available, a new user is created. Encryption is handled by... hook pre('save') configured in the modelTherefore, at this point you don't need to worry about calling bcrypt directly.

Login endpoint and JWT token issuance

The next step is to allow an existing user to Authenticate and receive your JWT tokenTo do this, you create a login endpoint where the credentials are validated and, if they are correct, a token is signed with the necessary information:

app.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username });

if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}

const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}

const token = jwt.sign(
{ id: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);

res.json({ token });
} catch (error) {
res.status(500).json({ message: 'Error logging in' });
}
});

The function jwt.sign() It receives three arguments: an object containing the information to be included in the token, the secret key, and an options object. In this case, a 1 hour expiration time using the property expiresInThis way the token will not be valid indefinitely and the user will have to authenticate again after that time.

The field you include in the payload (id: user._idThis will be the identifier you'll use in protected routes to identify the user sending the request. You could add, for example, the user's role if you want to manage more complex permissions.

Middleware to protect routes with JWT

Having a token by itself isn't very useful if You don't explicitly protect certain routes in your APITo achieve this, middleware is typically created to validate the JWT on each request to these sensitive endpoints. A simple example of such middleware could be this:

function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Token not provided' });
}

const token = authHeader.split(' ');

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: 'Invalid or expired token' });
}
}

This middleware checks that the Authorization header exists and follows the correct format, extracts the token, and validates it with jwt.verify() And, if all goes well, attach the decoded data to the req object so that the rest of the logic can determine the user's identity. If the token is invalid or has expired, a 401 error is returned.

Once the middleware is defined, you can apply it to any route you want to protect. For example:

app.get('/perfil', authMiddleware, async (req, res) => {
try {
const user = await User.findById(req.user.id).select('-password');
res.json(user);
} catch (error) {
res.status(500).json({ message: 'Error al obtener el perfil' });
}
});

With this pattern, any route you add after authMiddleware It will be limited to users who present a valid token in their request.

Direct use of jsonwebtoken to generate and verify tokens

Besides integrating it with Express, it's useful to see how the... two key functions of the jsonwebtoken librarysince they can be useful in scripts, tests, or auxiliary tools.

The first is jwt.sign()which is responsible for creating a new token from a payload, a secret, and optionally, a set of options. A generic example would be something like this:

const jwt = require('jsonwebtoken');

const token = jwt.sign(
{ id: user.id },
'your-secret-key',
{ expiresIn: '1h' }
);

The second is jwt.verify()which receives the token and the same key used to sign. If everything matches, it returns the decoded data; otherwise, it throws an error that you can capture and handle appropriately:

try {
const decodificado = jwt.verify(token, 'tu-clave-secreta');
console.log(decodificado.id);
} catch (err) {
console.error('Token no válido');
}

Understanding how these two calls work helps you to mastering the token creation and validation flow You can now debug authentication problems in your API or in clients that consume your services.

Server startup and testing with external tools

Once you have everything wired (models, endpoints, middleware, and database connection), all that remains is Start the Express server and begin testing the APIThe basic setup usually involves something like:

app.listen(3000, () => {
console.log('Servidor funcionando en el puerto 3000');
});

From there you can use Postman, Insomnia, or similar tools to trigger requests HTTP to registration, login, and protected route endpointsA typical test flow might be:

  • Send a POST request to /register with username and password on the body.
  • Verify that the user is created and some type of confirmation is returned.
  • Make a post /login with those same credentials and receive a token in the response.
  • Copy that token and use it in the Authorization header to call a protected route, such as /perfilverifying that the server accepts it and returns the expected data.

This entire process allows you to validate that the JWT authentication flow behaves consistent from end to end and that the server responds correctly according to the validity or expiration of the token.

How to enable JavaScript on an Android phone
Related article:
How to enable JavaScript on an Android phone

With this entire structure in place, you now have a robust, scalable, and fairly easy-to-maintain JWT authentication system in Node.js and Express; now you can fine-tune security details, add roles, refresh tokens, or any other functionality your API needs to be ready for production. Share this information and help others enable JWT authentication using Node.js