Authentication is one of the most important parts of modern web development. Whether you're building a blog, an eCommerce platform, a SaaS application, or a REST API, you need a secure way to identify users and protect private resources.
One of the most widely used authentication methods today is JWT (JSON Web Token). It is lightweight, scalable, secure, and works perfectly with modern technologies like React, Next.js, Vue, Flutter, Android, iOS, and REST APIs.
Despite its popularity, JWT is often misunderstood by beginners. Questions like "What exactly is a JWT?", "Why do we need Access Tokens and Refresh Tokens?", and "How does the server know if a user is authenticated without storing sessions?" can make the topic seem confusing.
The good news is that JWT is much easier to understand than it appears. Once you understand how the authentication flow works, you'll be able to implement secure login systems for almost any web application.
Before implementing JWT authentication in a real-world project, it's important to understand how secure REST APIs are designed. Concepts like authentication, request validation, HTTP status codes, and endpoint structure work together to build reliable APIs. If you're new to API architecture, don't miss our API Design Best Practices Guide, which explains the fundamentals every backend developer should know.
What You'll Learn
This tutorial is designed to help you understand both the theory and practical implementation of JWT Authentication.
- Understand what JWT is and why developers use it.
- Learn how JWT authentication works internally.
- Understand the Header, Payload, and Signature.
- Learn the difference between Access Tokens and Refresh Tokens.
- Build a complete authentication system using Node.js and Express.
- Generate and verify JWT tokens.
- Hash passwords securely using bcrypt.
- Create protected API routes using authentication middleware.
- Follow production-level security best practices.
What is JWT?
JWT, short for JSON Web Token, is an open standard used to securely exchange information between two parties. Instead of storing user login sessions on the server, JWT stores user-related information inside a digitally signed token.
When a user logs in successfully, the server generates a token and sends it back to the client. The client stores this token and includes it with every protected request. Before processing the request, the server verifies the token to confirm that it hasn't been modified.
Unlike traditional session-based authentication, the server doesn't need to remember every logged-in user. The token itself contains the information required to identify the user, making JWT a stateless authentication solution.
Stateless authentication becomes especially powerful when building scalable applications that run across multiple servers or microservices. If you'd like to understand how authentication fits into large-scale application architecture, read our Backend System Design for Beginners guide. It explains the core concepts behind scalable backend systems used in production.
Understanding JWT with a Simple Example
Imagine you're staying at a hotel.
During check-in, the receptionist verifies your identity using your ID card or passport. Once your identity has been verified, you're given a room key card.
From that moment onward, you don't need to show your passport every time you enter your room. You simply tap the key card, and the door checks whether the card is valid.
JWT works in exactly the same way.
| Hotel Example | JWT Authentication |
|---|---|
| Reception verifies your identity. | User logs in with email and password. |
| Hotel issues a room key. | Server generates a JWT. |
| You carry the key. | Client stores the JWT. |
| Door verifies the key. | Server verifies the JWT signature. |
| Door opens. | Protected API returns data. |
This approach eliminates the need to send the username and password with every request, improving both security and performance.
Why Has JWT Become So Popular?
Modern applications rarely consist of just a single website. Today, a typical application may include a web frontend, mobile applications, third-party integrations, and multiple backend services.
All of these clients need a consistent and secure way to authenticate users.
JWT solves this problem by allowing the same authentication token to be used across different platforms. Whether the client is a React application, an Android app, or a REST API consumer, the authentication process remains the same.
Another major advantage is scalability. Since the server doesn't need to store session data, any server with the correct secret key can verify the token. This makes JWT an excellent choice for distributed systems, cloud deployments, and microservices.
How JWT Authentication Works
Now that you know what JWT is, the next step is understanding how it actually works behind the scenes.
Many beginners think that JWT authentication is a complex process because it involves tokens, signatures, and secret keys. In reality, the entire authentication flow is quite straightforward. Once you understand the sequence of events, implementing JWT in your own applications becomes much easier.
The basic idea is simple. After verifying the user's credentials, the server creates a signed token and sends it to the client. Every protected request includes this token, allowing the server to verify the user's identity without storing login sessions.
The Complete JWT Authentication Flow
Let's walk through the complete login process step by step.
- The user enters their email and password on the login page.
- The browser sends these credentials to the backend server.
- The server validates the email and password by checking the database.
- If the credentials are correct, the server generates a signed JWT.
- The JWT is sent back to the client.
- The client stores the token securely.
- Whenever the user accesses a protected API, the token is sent along with the request.
- The server verifies the token's signature.
- If the token is valid, the requested resource is returned.
Although this process contains several steps, the entire operation usually takes only a fraction of a second.
Visualizing the Authentication Flow
The following diagram shows how communication happens between the client and the server.
Authentication is only one part of a well-designed API. To build scalable and maintainable backend services, you should also understand endpoint naming, request validation, response formats, and versioning. Our REST API Design Best Practices Guide walks through these concepts with practical examples for modern applications.
User
│
│ Login (Email + Password)
▼
Backend Server
│
│ Verify Credentials
▼
Database
│
│ User Found
▼
Backend Server
│
│ Generate JWT
▼
Client
│
│ Store Token
▼
Protected API Request
│
│ Authorization: Bearer <JWT>
▼
Backend Server
│
│ Verify JWT
▼
Protected Resource
Notice that the user's password is sent only once during login. Every request after that uses the JWT instead.
Where Is the JWT Stored?
After the server generates a JWT, the client must store it somewhere so it can be included in future requests.
There are several storage options, and each has its own advantages and disadvantages.
| Storage Method | Recommended | Notes |
|---|---|---|
| HTTP-Only Cookies | ✅ Yes | Most secure option for web applications. |
| Memory | ✅ Yes | Common for Single Page Applications. |
| Local Storage | ⚠ Use Carefully | Easy to use but vulnerable to XSS attacks. |
| Session Storage | ⚠ Limited | Token is removed when the browser tab closes. |
Sending JWT with Every Request
Once the client has stored the token, it must include it whenever requesting protected resources.
The standard approach is to send the token inside the Authorization header using the Bearer scheme.
GET /api/profile HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
When the server receives the request, it extracts the token from the Authorization header and verifies its signature.
If the signature is valid and the token hasn't expired, the request is processed successfully.
How Does the Server Verify the Token?
One common misconception is that the server simply trusts any JWT it receives. This is not true.
Every incoming token is verified using the same secret key that was used when the token was created.
If someone changes even a single character in the token, the signature no longer matches and the request is immediately rejected.
Receive JWT
│
▼
Extract Signature
│
▼
Generate New Signature
Using Secret Key
│
▼
Compare Both Signatures
│
┌────┴────┐
│ │
Match Not Match
│ │
Allow Reject
Why Doesn't the Server Store Sessions?
Traditional authentication stores session data on the server.
Every request requires the server to locate the corresponding session before deciding whether the user is authenticated.
JWT works differently.
Because the token already contains the user's identity and is digitally signed, the server only needs to verify the token. There's no need to search for session data in memory or a database.
This makes JWT particularly useful for applications that receive thousands or even millions of API requests every day.
Key Takeaways
- Users authenticate only once using their email and password.
- The server generates a signed JWT after successful login.
- The client stores the JWT and sends it with every protected request.
- The server verifies the token before returning protected data.
- No server-side sessions are required.
- This stateless architecture makes JWT fast, scalable, and ideal for REST APIs.
Understanding the JWT Structure
At first glance, a JWT looks like a long string of random characters. Many beginners assume it's encrypted data that only the server can read.
In reality, that's not how JWT works.
A JSON Web Token consists of three separate parts, each with a specific purpose. These parts are joined together using a period (.) to create a complete token.
What Does a JWT Look Like?
A typical JWT looks something like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MSwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6InVzZXIifQ.
M4H9K8d0Qw7xL3vVv2nP8Qm5Yg8nWm1S5pQwXyZaBcD
Although it appears to be a single string, it actually contains three different sections.
Header.Payload.Signature
Each section has a unique responsibility during the authentication process.
The Three Parts of a JWT
| Part | Purpose |
|---|---|
| Header | Stores metadata about the token, including the signing algorithm. |
| Payload | Contains user information and token claims. |
| Signature | Protects the token from being modified. |
Let's understand each part in detail.
Part 1: Header
The Header contains basic information about the token.
It tells the server which algorithm was used to generate the digital signature and confirms that the token is a JWT.
A typical Header looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
Let's understand these properties.
| Property | Description |
|---|---|
alg |
Specifies the algorithm used to create the signature. |
typ |
Indicates that the token type is JWT. |
The most commonly used signing algorithm is HS256, which uses a secret key shared by the server to generate and verify the token.
Part 2: Payload
The Payload contains information about the authenticated user.
These pieces of information are known as Claims.
Claims describe who the user is, when the token was issued, when it expires, and any additional information the application needs.
A simple Payload might look like this:
{
"id": 101,
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
Production applications usually include additional claims such as token expiration and issue time.
| Claim | Description |
|---|---|
id |
Unique identifier of the user. |
email |
User's email address. |
role |
User's permission level. |
iat |
Time when the token was created. |
exp |
Token expiration time. |
Types of JWT Claims
JWT claims are generally divided into three categories.
| Claim Type | Purpose | Examples |
|---|---|---|
| Registered Claims | Standard claims defined by the JWT specification. | exp, iat, iss, sub |
| Public Claims | Custom information shared between different systems. | role, department |
| Private Claims | Application-specific information. | userId, subscriptionPlan |
Part 3: Signature
The Signature is the most important part of a JWT.
Its job is to ensure that the token hasn't been modified after it was generated.
When the server creates a JWT, it combines the encoded Header and Payload with a secret key to generate a unique signature.
Every time the client sends the token back, the server generates a new signature using the same secret key.
If both signatures match, the token is valid.
Header
+
Payload
+
Secret Key
│
▼
Generate Signature
│
▼
JWT Token
During verification, the process works like this:
Receive JWT
│
▼
Decode Header & Payload
│
▼
Generate New Signature
Using Secret Key
│
▼
Compare Both Signatures
│
┌────┴────┐
│ │
Match Different
│ │
Valid Invalid
Can Someone Read My JWT?
Yes.
Anyone who has access to a JWT can decode its Header and Payload using freely available online tools or programming libraries.
However, decoding a JWT is completely different from modifying it.
Without the server's secret key, it's practically impossible to generate a valid Signature after changing the Payload.
Key Takeaways
- A JWT consists of three parts: Header, Payload, and Signature.
- The Header describes the token.
- The Payload stores user information and claims.
- The Signature protects the token from tampering.
- The Header and Payload are encoded, not encrypted.
- The server verifies the Signature before trusting the token.
Access Token vs Refresh Token
One of the most common questions beginners ask is:
"If JWT already authenticates the user, why do we need two different tokens?"
The answer is simple: security.
If a JWT token never expired, anyone who stole that token could access the user's account indefinitely. To reduce this risk, modern authentication systems divide authentication into two separate tokens: an Access Token and a Refresh Token.
What is an Access Token?
An Access Token is a short-lived JWT that allows users to access protected resources such as API endpoints, user profiles, dashboards, and private data.
Whenever the client sends a request to a protected API, this token is included in the Authorization header.
The server verifies the token, and if it's valid, the requested resource is returned.
Because Access Tokens are sent frequently, they should have a short expiration time.
| Property | Access Token |
|---|---|
| Purpose | Access protected APIs |
| Lifetime | Usually 15–30 minutes |
| Sent with Every Request | Yes |
| Expires Automatically | Yes |
What is a Refresh Token?
A Refresh Token is a long-lived token that is used to generate a new Access Token after the current one expires.
Unlike the Access Token, it is not sent with every API request.
Instead, it is used only when the application needs to obtain a new Access Token without forcing the user to log in again.
This provides a much smoother user experience while maintaining a high level of security.
| Property | Refresh Token |
|---|---|
| Purpose | Generate new Access Tokens |
| Lifetime | Several days or weeks |
| Sent with Every Request | No |
| Stored Securely | Yes |
Why Do We Need Two Tokens?
Imagine your Access Token expires after just 15 minutes.
Without a Refresh Token, users would need to log in again every 15 minutes, which would create a frustrating experience.
Instead, when the Access Token expires, the application silently sends the Refresh Token to the server.
If the Refresh Token is still valid, the server generates a brand-new Access Token and returns it to the client.
The user continues using the application without noticing anything.
How the Token Refresh Process Works
The following steps explain the complete lifecycle of Access and Refresh Tokens.
- The user logs in using their email and password.
- The server verifies the credentials.
- The server generates both an Access Token and a Refresh Token.
- The client stores both tokens securely.
- The Access Token is used for protected API requests.
- After the Access Token expires, the client sends the Refresh Token to the server.
- The server verifies the Refresh Token.
- If the Refresh Token is valid, a new Access Token is generated.
- The client continues making API requests using the new Access Token.
Authentication Flow with Refresh Tokens
User Login
│
▼
Server Verifies Credentials
│
▼
Generate Access Token
Generate Refresh Token
│
▼
Client Stores Tokens
│
▼
API Request
│
▼
Access Token Expires
│
▼
Send Refresh Token
│
▼
Server Verifies Refresh Token
│
▼
Generate New Access Token
│
▼
Continue Using Application
Where Should Tokens Be Stored?
Choosing the right storage location is just as important as generating secure tokens.
| Storage Option | Access Token | Refresh Token |
|---|---|---|
| HTTP-Only Cookie | ✅ Recommended | ✅ Highly Recommended |
| Memory (React State) | ✅ Good Option | ❌ Not Ideal |
| Local Storage | ⚠ Acceptable with caution | ❌ Not Recommended |
| Session Storage | ⚠ Temporary | ❌ Not Recommended |
Access Token vs Refresh Token
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Access APIs | Generate new Access Tokens |
| Expiration | Short | Long |
| Sent Frequently | Yes | No |
| Security Risk if Stolen | Lower | Higher |
| Recommended Storage | HTTP-Only Cookie | HTTP-Only Cookie |
Common Mistakes Beginners Make
- Creating Access Tokens that never expire.
- Using only one token for everything.
- Storing sensitive information inside JWT Payloads.
- Saving Refresh Tokens in Local Storage.
- Skipping Refresh Token verification.
- Using weak secret keys such as
123456orpassword.
Key Takeaways
- Access Tokens authenticate users when accessing protected resources.
- Refresh Tokens generate new Access Tokens without requiring users to log in again.
- Access Tokens should expire quickly to reduce security risks.
- Refresh Tokens should be stored securely, preferably in HTTP-Only cookies.
- Using both tokens provides a balance between security and user experience.
Setting Up the JWT Authentication Project
So far, we've learned the theory behind JWT Authentication. Now it's time to put that knowledge into practice by building a complete authentication system using Node.js, Express.js, and MongoDB.
Instead of creating a simple demo project, we'll follow a clean folder structure that's commonly used in production applications. This makes the project easier to understand, maintain, and scale as new features are added.
By the end of this implementation, you'll have a secure authentication API that supports user registration, login, password hashing, JWT generation, authentication middleware, and protected routes.
Technologies We'll Use
Before writing any code, let's understand the technologies used throughout this project and why each one is important.
| Technology | Purpose |
|---|---|
| Node.js | Runs JavaScript code on the server. |
| Express.js | Creates REST APIs and handles incoming HTTP requests. |
| MongoDB | Stores user information such as names, emails, and hashed passwords. |
| Mongoose | Provides an easy way to interact with MongoDB using models. |
| bcrypt | Hashes passwords before storing them in the database. |
| jsonwebtoken | Generates and verifies JWT tokens. |
| dotenv | Loads secret values like JWT_SECRET and MongoDB URI from a .env file. |
| nodemon | Automatically restarts the server whenever files change. |
Step 1: Create a New Node.js Project
Open your terminal and create a new project folder.
mkdir jwt-auth-app
cd jwt-auth-app
npm init -y
The npm init -y command creates a package.json file that stores information about your project, installed packages, and npm scripts.
Step 2: Install Required Packages
Next, install all the dependencies required for authentication.
npm install express mongoose jsonwebtoken bcrypt dotenv
Install nodemon as a development dependency.
npm install --save-dev nodemon
Using nodemon saves time because it automatically restarts the server whenever you modify your code.
Understanding the Installed Packages
If you're new to Node.js, it's useful to understand why each package was installed.
| Package | Why We Need It |
|---|---|
express |
Creates the backend server and API routes. |
mongoose |
Connects Node.js with MongoDB. |
jsonwebtoken |
Creates and verifies JWT access tokens. |
bcrypt |
Encrypts user passwords before saving them. |
dotenv |
Loads environment variables securely. |
nodemon |
Restarts the server automatically during development. |
Step 3: Organize the Project Structure
A clean folder structure makes backend applications much easier to maintain.
Create the following directories inside your project.
jwt-auth-app/
│
├── config/
├── controllers/
├── middleware/
├── models/
├── routes/
├── utils/
│
├── .env
├── server.js
├── package.json
└── package-lock.json
Each folder has a specific responsibility.
| Folder | Purpose |
|---|---|
config |
Stores database configuration. |
controllers |
Contains API business logic. |
middleware |
Handles authentication and request validation. |
models |
Defines MongoDB schemas. |
routes |
Stores API routes. |
utils |
Contains reusable helper functions. |
Step 4: Configure package.json
Open the package.json file and replace the default scripts section with the following.
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
You can now start the development server using:
npm run dev
Whenever you save a file, nodemon automatically reloads the application.
Step 5: Create the Environment File
Create a new file named .env in the root directory.
PORT=5000
MONGO_URI=your_mongodb_connection_string
JWT_SECRET=your_super_secret_jwt_secret
The JWT_SECRET is one of the most important parts of your authentication system because it is used to sign and verify every JWT.
.env file to GitHub. Always add it to your .gitignore file. Exposing your JWT secret allows attackers to generate valid authentication tokens.
Why Environment Variables Matter
Imagine your application runs on your local computer today and on a cloud server tomorrow.
Each environment will use different database credentials, ports, and secret keys.
Instead of modifying your source code every time, environment variables allow you to keep these values separate from the application.
This improves both security and flexibility.
Connecting MongoDB and Creating the User Model
Our project structure is now ready, but users still have nowhere to register because the application isn't connected to a database.
Authentication systems need a secure place to store user information such as names, email addresses, and hashed passwords. In this tutorial, we'll use MongoDB, one of the most popular NoSQL databases used with Node.js applications.
To interact with MongoDB easily, we'll use Mongoose, an Object Data Modeling (ODM) library that allows us to create schemas and models instead of writing raw database queries.
Step 1: Create the Database Configuration
Inside the config folder, create a file named db.js.
This file will be responsible for connecting our application to MongoDB.
const mongoose = require("mongoose");
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGO_URI);
console.log("MongoDB Connected Successfully");
} catch (error) {
console.error(error.message);
process.exit(1);
}
};
module.exports = connectDB;
Let's understand what's happening here.
| Code | Purpose |
|---|---|
mongoose.connect() |
Creates a connection with the MongoDB database. |
process.env.MONGO_URI |
Reads the connection string from the .env file. |
try...catch |
Handles connection errors gracefully. |
process.exit(1) |
Stops the application if the database connection fails. |
Step 2: Connect MongoDB to the Server
Now we need to call the database connection function when the application starts.
Open your server.js file and import the database configuration.
const express = require("express");
const dotenv = require("dotenv");
const connectDB = require("./config/db");
dotenv.config();
connectDB();
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Now, every time the server starts, it first establishes a connection with MongoDB before handling incoming requests.
As your application grows, keeping your project organized becomes just as important as writing clean code. Structuring configuration files, database connections, models, and controllers properly makes your backend easier to maintain and scale. Learn the recommended architecture in our Node.js Project Structure Guide.
Step 3: Create the User Model
The next step is defining how user data should be stored inside MongoDB.
Instead of allowing random data, we'll create a schema that defines exactly which fields every user document should contain.
Create a file named User.js inside the models folder.
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
required: true
}
}, {
timestamps: true
});
module.exports = mongoose.model("User", userSchema);
This schema defines the structure of every user stored in the database.
Understanding the User Schema
| Field | Description |
|---|---|
name |
Stores the user's full name. |
email |
Stores a unique email address used for login. |
password |
Stores the hashed password, never the original password. |
timestamps |
Automatically creates createdAt and updatedAt fields. |
Notice that we haven't stored any JWT tokens inside the database.
This is intentional because JWT authentication is stateless. Access Tokens are generated after login and verified using the server's secret key instead of being permanently stored.
Why Passwords Should Never Be Stored in Plain Text
One of the biggest security mistakes beginners make is saving passwords exactly as users type them.
Password security is only one part of building secure backend applications. A production-ready API should also validate requests, sanitize user input, handle errors properly, and protect sensitive endpoints. Our API Design Best Practices Guide explains these backend security fundamentals in detail.
Imagine a user registers with the following password.
MyPassword@123
If this password is stored directly in the database, anyone who gains access to the database can immediately read it.
This creates a serious security risk because many people reuse the same password across multiple websites.
What is Password Hashing?
Password hashing converts a readable password into a long, irreversible string of characters.
Unlike encryption, hashing cannot be reversed to recover the original password.
For example:
Original Password
MyPassword@123
↓
Hashed Password
$2b$10$N4Tp8YQY8L2fL9Y3v8E2PeA7k5m9lBqY5KQ4YwM2h7zQWz1rP8A8G
Even if two users choose the same password, bcrypt generates different hashes by adding a unique random salt before hashing.
Why Use bcrypt?
bcrypt is one of the most trusted password hashing libraries available for Node.js.
It automatically generates a unique salt for every password, making brute-force and rainbow table attacks much more difficult.
| Feature | Benefit |
|---|---|
| Automatic Salt Generation | Produces a unique hash even for identical passwords. |
| Slow Hashing Algorithm | Makes brute-force attacks much harder. |
| Widely Trusted | Used in countless production applications. |
| Easy Verification | Compares plain passwords with stored hashes securely. |
Building the User Registration API
Our application is now connected to MongoDB, and we have a User Model ready to store user information.
The next step is allowing users to create an account.
At first glance, a registration API may seem simple because it only receives a user's name, email, and password. However, a secure registration system performs several checks before saving any data to the database.
A production-ready registration API should:
- Validate the incoming data.
- Check whether the email address already exists.
- Hash the password using bcrypt.
- Store only the hashed password in the database.
- Return an appropriate success or error response.
How the Registration Process Works
Before writing any code, let's understand what happens when a user clicks the Sign Up button.
User
│
│ Name, Email, Password
▼
Registration API
│
│ Validate Input
▼
Check Email Exists?
│
┌─┴─────────┐
│ │
Yes No
│ │
Return Hash Password
Error │
▼
Save User
│
▼
Success Response
Notice that the password is hashed before it's saved to MongoDB.
Step 1: Create the Authentication Controller
Create a new file named authController.js inside the controllers folder.
This file will contain all authentication-related business logic, including registration and login.
const User = require("../models/User");
const bcrypt = require("bcrypt");
const registerUser = async (req, res) => {
try {
// Registration logic goes here
} catch (error) {
res.status(500).json({
message: "Server Error"
});
}
};
module.exports = {
registerUser
};
At this stage, we've created a basic controller function. Next, we'll gradually add validation, password hashing, and database operations.
Step 2: Read User Input
The client sends user information in the request body.
We can extract these values using object destructuring.
const { name, email, password } = req.body;
After this line executes, the three variables contain the information submitted by the user.
Step 3: Validate Required Fields
Before interacting with the database, make sure all required fields are present.
if (!name || !email || !password) {
return res.status(400).json({
message: "Please fill all required fields."
});
}
This prevents incomplete or invalid requests from reaching the database.
Step 4: Check Whether the User Already Exists
Every email address should be unique.
Before creating a new account, search the database to see whether another user has already registered using the same email.
const existingUser = await User.findOne({
email
});
if (existingUser) {
return res.status(409).json({
message: "Email already registered."
});
}
If a matching document is found, the API immediately returns an error response instead of creating a duplicate account.
findOne() is efficient because it stops searching as soon as the first matching document is found.
Step 5: Hash the Password
Now comes one of the most important security steps.
Instead of saving the original password, we'll convert it into a secure hash using bcrypt.
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
Here:
| Method | Purpose |
|---|---|
genSalt(10) |
Creates a random salt with 10 rounds. |
hash() |
Generates the final hashed password. |
Even if two users choose the same password, bcrypt generates different hashes because each password receives its own unique salt.
Step 6: Save the User
After hashing the password, we can safely create a new user document.
const user = await User.create({
name,
email,
password: hashedPassword
});
Notice that we're storing hashedPassword instead of the original password.
Step 7: Return a Success Response
Once the user has been saved successfully, return a response indicating that the registration process has completed.
res.status(201).json({
success: true,
message: "User registered successfully."
});
A 201 Created status code indicates that a new resource has been created successfully.
Complete Registration Controller
Combining all the previous steps gives us the complete registration controller.
const User = require("../models/User");
const bcrypt = require("bcrypt");
const registerUser = async (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({
message: "Please fill all required fields."
});
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({
message: "Email already registered."
});
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
await User.create({
name,
email,
password: hashedPassword
});
res.status(201).json({
success: true,
message: "User registered successfully."
});
} catch (error) {
res.status(500).json({
message: "Server Error"
});
}
};
module.exports = {
registerUser
};
Key Takeaways
- Always validate incoming user data.
- Check for duplicate email addresses before creating an account.
- Never store plain text passwords.
- Use bcrypt to hash passwords before saving them.
- Return meaningful HTTP status codes for success and errors.
Building the User Login API
Our registration API is now complete. Users can create an account, and their passwords are securely stored as bcrypt hashes.
The next step is allowing users to log in.
Unlike registration, the login process doesn't create a new user. Instead, it verifies the user's credentials and, if everything is correct, generates a JWT that can be used to access protected resources.
This JWT acts as a temporary digital identity card. As long as the token is valid, the user doesn't need to enter their email and password again.
How the Login Process Works
Before writing any code, let's understand the complete authentication flow.
User
│
│ Email + Password
▼
Login API
│
▼
Find User by Email
│
┌─┴─────────┐
│ │
Not Found Found
│ │
Return Compare Password
Error │
▼
Password Correct?
│
┌─────┴─────┐
│ │
No Yes
│ │
Return Error Generate JWT
│
▼
Return Token to Client
Step 1: Import Required Packages
Open the controllers/authController.js file and import the jsonwebtoken package.
const jwt = require("jsonwebtoken");
We'll use this package to generate signed JWTs after successful authentication.
Step 2: Create the Login Controller
Create a new controller function named loginUser.
const loginUser = async (req, res) => {
try {
// Login logic
} catch (error) {
res.status(500).json({
message: "Server Error"
});
}
};
Step 3: Read User Credentials
Extract the email and password from the request body.
const { email, password } = req.body;
These are the credentials submitted by the user during login.
Step 4: Validate the Input
Always verify that the required fields have been provided.
if (!email || !password) {
return res.status(400).json({
message: "Email and Password are required."
});
}
Skipping validation may lead to unnecessary database queries and unexpected application errors.
Step 5: Find the User
Search the database using the provided email address.
const user = await User.findOne({
email
});
If no matching account exists, return an authentication error.
if (!user) {
return res.status(401).json({
message: "Invalid email or password."
});
}
Email does not exist because attackers can use them to discover registered accounts. A generic error message is more secure.
Step 6: Compare the Password
Now compare the password entered by the user with the hashed password stored in MongoDB.
const isPasswordValid = await bcrypt.compare(
password,
user.password
);
If the passwords don't match, reject the login request.
if (!isPasswordValid) {
return res.status(401).json({
message: "Invalid email or password."
});
}
Step 7: Generate the JWT
Once the user's identity has been verified, generate a JWT.
const token = jwt.sign(
{
id: user._id,
email: user.email
},
process.env.JWT_SECRET,
{
expiresIn: "1h"
}
);
This code creates a signed JWT containing the user's ID and email address.
| Property | Purpose |
|---|---|
id |
Identifies the authenticated user. |
email |
Useful for quick user identification. |
JWT_SECRET |
Signs the token securely. |
expiresIn |
Automatically expires the token after one hour. |
Step 8: Return the Token
Finally, send the generated JWT back to the client.
res.status(200).json({
success: true,
message: "Login successful.",
token
});
The frontend stores this token and sends it in the Authorization header whenever it accesses protected API routes.
Complete Login Controller
const loginUser = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
message: "Email and Password are required."
});
}
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({
message: "Invalid email or password."
});
}
const isPasswordValid = await bcrypt.compare(
password,
user.password
);
if (!isPasswordValid) {
return res.status(401).json({
message: "Invalid email or password."
});
}
const token = jwt.sign(
{
id: user._id,
email: user.email
},
process.env.JWT_SECRET,
{
expiresIn: "1h"
}
);
res.status(200).json({
success: true,
message: "Login successful.",
token
});
} catch (error) {
res.status(500).json({
message: "Server Error"
});
}
};
Key Takeaways
- Always validate the login request before accessing the database.
- Use a generic error message for failed logins.
- Use
bcrypt.compare()to verify passwords. - Generate a signed JWT only after successful authentication.
- Set an expiration time for every Access Token.
- Return the JWT to the client so it can be used for future authenticated requests.
Protecting Routes with JWT Authentication Middleware
Our authentication system is almost complete.
Users can now register, log in, and receive a valid JWT after successful authentication. However, there's still one major problem.
At the moment, anyone can access every API endpoint because our server isn't verifying whether incoming requests contain a valid token.
This is where Authentication Middleware becomes essential.
Middleware acts like a security checkpoint. Before a request reaches a protected route, the middleware checks whether the user has provided a valid JWT. If the token is valid, the request continues. Otherwise, access is denied immediately.
Authentication middleware is only one layer of backend security. As applications grow, middleware also handles logging, validation, rate limiting, and error handling to keep APIs secure and maintainable. If you're interested in designing scalable backend architectures, check out our Backend System Design for Beginners guide to see how these components work together in production.
How Authentication Middleware Works
Let's first understand the request flow before writing any code.
Client
│
│ GET /api/profile
│
Authorization: Bearer JWT
│
▼
Authentication Middleware
│
▼
Verify JWT
│
┌──┴──────────┐
│ │
Valid Invalid
│ │
▼ ▼
Next() Return 401
│
▼
Protected Route
│
▼
Response
Notice that the request never reaches the protected route unless the middleware successfully verifies the JWT.
Step 1: Create the Authentication Middleware
Create a new file named authMiddleware.js inside the middleware folder.
This file will contain all JWT verification logic.
const jwt = require("jsonwebtoken");
const protect = async (req, res, next) => {
// JWT verification logic
};
module.exports = protect;
Every protected route will use this middleware before executing its controller.
Step 2: Read the Authorization Header
Whenever a client sends a protected request, the JWT should be included inside the Authorization header.
A typical request looks like this:
GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Inside the middleware, we first retrieve this header.
const authHeader = req.headers.authorization;
If the header doesn't exist, the request should be rejected immediately.
if (!authHeader) {
return res.status(401).json({
message: "Authorization header is missing."
});
}
Step 3: Verify the Bearer Token Format
The Authorization header should always begin with the word Bearer.
This tells the server that the remaining value is a JWT.
if (!authHeader.startsWith("Bearer ")) {
return res.status(401).json({
message: "Invalid authorization format."
});
}
If the request doesn't follow the correct format, there's no reason to continue processing it.
Step 4: Extract the JWT
Once the Bearer format has been verified, we can separate the token from the header.
const token = authHeader.split(" ")[1];
For example:
Authorization: Bearer abc.xyz.123
↓
abc.xyz.123
The extracted token is now ready for verification.
Step 5: Verify the JWT
The next step is verifying the token using the same secret key that was used during login.
const decoded = jwt.verify(
token,
process.env.JWT_SECRET
);
If the token has been modified or has already expired, jwt.verify() throws an error automatically.
If verification succeeds, it returns the decoded payload.
| Scenario | Result |
|---|---|
| Valid Token | Decoded payload is returned. |
| Expired Token | Error is thrown. |
| Modified Token | Verification fails. |
| Wrong Secret Key | Verification fails. |
Step 6: Attach User Information
After successful verification, the decoded payload becomes available.
Instead of decoding the token repeatedly in every controller, we can attach the user information directly to the request object.
req.user = decoded;
next();
The next() function tells Express to continue processing the request and execute the next middleware or route handler.
req.user.
Complete Authentication Middleware
const jwt = require("jsonwebtoken");
const protect = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
message: "Authorization header is missing."
});
}
if (!authHeader.startsWith("Bearer ")) {
return res.status(401).json({
message: "Invalid authorization format."
});
}
const token = authHeader.split(" ")[1];
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."
});
}
};
module.exports = protect;
Using the Middleware
Now let's protect an API route.
Without middleware:
router.get("/profile", getProfile);
Anyone can access this route.
With middleware:
const protect = require("../middleware/authMiddleware");
router.get(
"/profile",
protect,
getProfile
);
Now the request must pass through the middleware before reaching the controller.
Accessing the Logged-in User
Because the middleware stores the decoded payload inside req.user, accessing the authenticated user's information becomes very simple.
const getProfile = async (req, res) => {
res.json({
message: "Protected Route",
user: req.user
});
};
You can now use req.user.id, req.user.email, or any other claim stored inside the JWT.
Key Takeaways
- Authentication middleware protects private API endpoints.
- JWTs are usually sent inside the
Authorization: Bearer <token>header. - The middleware verifies the token using
jwt.verify(). - If verification succeeds, the decoded payload is attached to
req.user. - If verification fails, the server immediately returns a
401 Unauthorizedresponse. - Protected routes should always use authentication middleware before executing business logic.
Implementing Refresh Tokens
At this point, our authentication system is fully functional. Users can register, log in, receive a JWT, and access protected API routes.
However, there's one problem with our current implementation.
When the Access Token expires, the user is forced to log in again. While this improves security, it also creates a poor user experience, especially for applications where users stay logged in for long periods.
The solution is to introduce Refresh Tokens.
Why Do We Need Refresh Tokens?
Imagine you've configured your Access Token to expire after 15 minutes.
A user logs into your application and starts working. After 15 minutes, the token expires.
If your application only uses Access Tokens, the user must enter their email and password again.
Now imagine this happening every 15 minutes.
That would quickly become frustrating.
Instead, the application silently sends a Refresh Token to the server. If it's valid, the server generates a brand-new Access Token without interrupting the user's session.
How Refresh Tokens Work
Let's understand the complete flow before writing any code.
User Login
│
▼
Server Generates
Access Token
Refresh Token
│
▼
Client Stores Tokens
│
▼
Access Token Expires
│
▼
Client Sends Refresh Token
│
▼
Server Verifies Refresh Token
│
▼
Generate New Access Token
│
▼
Continue Using Application
The user never notices this process because it happens automatically in the background.
Creating a Refresh Token
The process of generating a Refresh Token is almost identical to generating an Access Token.
The main difference is its expiration time.
Access Tokens usually expire within a few minutes, while Refresh Tokens remain valid for several days or even weeks.
const refreshToken = jwt.sign(
{
id: user._id
},
process.env.JWT_REFRESH_SECRET,
{
expiresIn: "7d"
}
);
| Token | Typical Expiration |
|---|---|
| Access Token | 15 Minutes |
| Refresh Token | 7 Days |
Updating the Environment Variables
Because Refresh Tokens use a different secret key, update your .env file.
PORT=5000
MONGO_URI=your_mongodb_connection_string
JWT_SECRET=your_access_token_secret
JWT_REFRESH_SECRET=your_refresh_token_secret
Creating the Refresh Token API
When the Access Token expires, the frontend sends the Refresh Token to a dedicated API endpoint.
The server verifies the Refresh Token and, if it's valid, generates a new Access Token.
const refreshAccessToken = async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
message: "Refresh Token is required."
});
}
const decoded = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET
);
const accessToken = jwt.sign(
{
id: decoded.id
},
process.env.JWT_SECRET,
{
expiresIn: "15m"
}
);
res.json({
accessToken
});
} catch (error) {
return res.status(401).json({
message: "Invalid Refresh Token."
});
}
};
This endpoint never asks the user for their email or password again. The Refresh Token itself is enough to issue a new Access Token.
Implementing Logout
Many beginners assume that logging out simply means deleting the Access Token from the browser.
While that removes the user's current session, the Refresh Token may still remain valid.
A proper logout implementation should invalidate the Refresh Token as well.
Simple Logout Example
If you're storing tokens inside HTTP-only cookies, logout becomes very straightforward.
const logoutUser = (req, res) => {
res.clearCookie("refreshToken");
res.status(200).json({
success: true,
message: "Logged out successfully."
});
};
After the cookie is removed, the client can no longer request new Access Tokens.
Best Practices for Refresh Tokens
| Practice | Recommendation |
|---|---|
| Store Refresh Tokens | Use HTTP-only cookies whenever possible. |
| Rotate Refresh Tokens | Issue a new Refresh Token whenever one is used. |
| Use Separate Secret Keys | Never share secrets between Access and Refresh Tokens. |
| Use HTTPS | Always encrypt token transmission. |
| Expire Refresh Tokens | Never allow permanent sessions. |
Common Mistakes
- Using the same secret key for every token.
- Giving Access Tokens very long expiration times.
- Storing Refresh Tokens in Local Storage.
- Never expiring Refresh Tokens.
- Ignoring logout functionality.
- Not verifying Refresh Tokens before issuing new Access Tokens.
Key Takeaways
- Access Tokens should expire quickly.
- Refresh Tokens generate new Access Tokens.
- Refresh Tokens should be stored securely.
- Use different secret keys for Access and Refresh Tokens.
- Implement proper logout functionality by removing or invalidating Refresh Tokens.
- Consider Refresh Token rotation for production applications.
JWT Security Best Practices
Building a JWT authentication system is only half the job. The other half is making sure it's secure.
Many applications implement JWT correctly from a functional perspective but overlook important security practices. These mistakes can expose user accounts, leak sensitive information, or allow attackers to bypass authentication.
Security doesn't stop after implementing JWT authentication. Progressive Web Apps (PWAs) also require secure communication over HTTPS, proper token handling, and safe client-side storage. If you're planning to build installable web applications, check out our Build a Simple Progressive Web App (PWA) Step-by-Step guide to learn how authentication integrates into modern web apps.
Fortunately, most of these issues are easy to avoid if you follow a few well-established best practices.
1. Always Use HTTPS
JWTs should always be transmitted over an encrypted HTTPS connection.
If your application uses plain HTTP, attackers on the same network may intercept authentication tokens while they're being transmitted.
Once an attacker obtains a valid token, they can impersonate the user until the token expires.
2. Never Store Sensitive Data Inside JWT
One of the biggest misconceptions about JWT is that it encrypts data.
It doesn't.
Anyone with access to a JWT can decode its Header and Payload using online tools.
Because of this, you should never store confidential information inside the token.
| Safe to Store | Never Store |
|---|---|
| User ID | Password |
| Email Address | OTP |
| User Role | Credit Card Number |
| Permissions | API Keys |
| Token Expiration | Personal Documents |
3. Use Strong Secret Keys
Your JWT is only as secure as the secret key used to sign it.
Weak secrets such as 123456, password, or mysecret can be guessed through brute-force attacks.
Always generate long, random, and unpredictable secret keys.
JWT_SECRET=wJ9@LkP2#vZ8!Qx4$dRm7Nf6YpT1Hs
4. Set Token Expiration
Never create JWTs that remain valid forever.
If an attacker steals a permanent token, they can continue using it indefinitely.
Instead, configure a reasonable expiration time.
| Token Type | Recommended Expiration |
|---|---|
| Access Token | 15–30 Minutes |
| Refresh Token | 7–30 Days |
5. Store Tokens Securely
Where you store your JWT is almost as important as how you generate it.
For web applications, HTTP-only cookies are generally the safest option because JavaScript cannot access them directly.
Access Tokens may also be stored in memory for Single Page Applications, depending on your architecture.
| Storage Location | Recommendation |
|---|---|
| HTTP-only Cookies | ⭐⭐⭐⭐⭐ Recommended |
| Application Memory | ⭐⭐⭐⭐ Good |
| Session Storage | ⭐⭐⭐ Acceptable |
| Local Storage | ⭐⭐ Use Carefully |
6. Validate Every Token
Never assume that every JWT received by your server is valid.
Always verify the token before processing any protected request.
A valid JWT should satisfy all of the following conditions:
- The token exists.
- The signature is valid.
- The token has not expired.
- The signing algorithm matches your expectations.
- The secret key is correct.
7. Keep JWT Payload Small
JWTs are sent with every authenticated request.
If the payload contains unnecessary information, every API request becomes larger, increasing network usage and reducing performance.
Only include the data your application actually needs.
8. Use Proper HTTP Status Codes
Returning meaningful HTTP status codes makes your API easier to understand and debug.
| Status Code | When to Use |
|---|---|
| 200 | Successful request |
| 201 | Resource created successfully |
| 400 | Invalid request data |
| 401 | User is not authenticated |
| 403 | User is authenticated but doesn't have permission |
| 500 | Internal server error |
Common JWT Security Mistakes
- Using permanent JWTs without expiration.
- Saving passwords inside JWT Payloads.
- Using weak or predictable secret keys.
- Skipping JWT verification.
- Trusting data from the client without validation.
- Returning detailed authentication errors to attackers.
- Using HTTP instead of HTTPS.
- Storing Refresh Tokens in Local Storage.
Production Security Checklist
| Recommendation | Status |
|---|---|
| Use HTTPS | ✅ |
| Hash Passwords with bcrypt | ✅ |
| Expire Access Tokens | ✅ |
| Use Refresh Tokens | ✅ |
| Verify Every JWT | ✅ |
| Store Tokens Securely | ✅ |
| Protect Sensitive Routes | ✅ |
| Use Environment Variables | ✅ |
JWT Authentication vs Session Authentication
If you've been learning backend development, you've probably come across another authentication method called Session Authentication. Since both JWT and Sessions solve the same problem, many beginners wonder which one they should choose.
The truth is that neither approach is universally better. Each has its own strengths, limitations, and ideal use cases.
Understanding these differences will help you choose the right authentication strategy for your next project.
What is Session Authentication?
Session Authentication is the traditional way of managing user logins.
After a successful login, the server creates a session and stores it in memory or a database. Instead of sending user information with every request, the browser only sends a Session ID stored inside a cookie.
Whenever the server receives a request, it looks up the Session ID, retrieves the user's session, and decides whether the request is authenticated.
User Login
│
▼
Server Verifies Credentials
│
▼
Create Session
│
▼
Store Session on Server
│
▼
Return Session ID Cookie
│
▼
Browser Sends Cookie
│
▼
Server Finds Session
│
▼
Access Granted
Unlike JWT, the server must remember every logged-in user.
JWT Authentication vs Session Authentication
| Feature | JWT Authentication | Session Authentication |
|---|---|---|
| Authentication Type | Stateless | Stateful |
| Server Stores Sessions | No | Yes |
| Scalability | Excellent | Good |
| Works with Mobile Apps | Excellent | Limited |
| REST API Support | Excellent | Good |
| Microservices | Excellent | More Complex |
| Server Memory Usage | Low | Higher |
| Easy Logout | Requires Token Handling | Very Easy |
Advantages of JWT Authentication
JWT has become the preferred authentication method for modern APIs because it offers several important benefits.
- No server-side session storage.
- Works seamlessly with REST APIs.
- Ideal for React, Next.js, Flutter, and mobile applications.
- Easy to scale across multiple servers.
- Supports distributed systems and microservices.
- Can be used across different programming languages.
Advantages of Session Authentication
Although JWT is extremely popular, Session Authentication still has several advantages.
- Simpler to implement for traditional websites.
- Sessions can be invalidated immediately.
- Less risk if a token is stolen because the server controls the session.
- Well suited for server-rendered applications.
If you're deciding which authentication approach fits your project, it's also worth considering the frontend framework you'll use. Different frameworks have different authentication patterns for handling protected routes and API communication. Read our React vs Next.js vs Angular vs Vue comparison to understand which framework best matches your backend architecture.
When Should You Use JWT?
JWT is usually the better choice when you're building API-first applications.
| Project Type | Recommended? |
|---|---|
| REST APIs | ✅ Yes |
| React Applications | ✅ Yes |
| Next.js Frontends | ✅ Yes |
| Flutter Applications | ✅ Yes |
| Android Apps | ✅ Yes |
| Microservices | ✅ Yes |
When Should You Use Session Authentication?
Sessions are still an excellent choice for many web applications.
| Project Type | Recommended? |
|---|---|
| Traditional PHP Websites | ✅ Yes |
| Laravel Applications | ✅ Yes |
| Django Applications | ✅ Yes |
| Ruby on Rails | ✅ Yes |
| Simple Internal Tools | ✅ Yes |
Common JWT Interview Questions
If you're preparing for backend interviews, these are some of the most frequently asked JWT-related questions.
What is JWT?
JWT (JSON Web Token) is a compact, digitally signed token used to securely transmit user information between a client and a server.
Why is JWT called stateless authentication?
Because the server doesn't store login sessions. Every request contains the information required to authenticate the user.
What are the three parts of a JWT?
Header, Payload, and Signature.
What is the purpose of the Signature?
The Signature ensures that the token hasn't been modified after it was generated.
Can JWT be decrypted?
The Header and Payload are encoded using Base64URL, not encrypted. Anyone can decode them, but they cannot modify the token without the secret key.
Why do we use bcrypt with JWT?
JWT handles authentication, while bcrypt securely hashes user passwords before they're stored in the database.
Frequently Asked Questions (FAQs)
Can JWT replace Session Authentication?
Not always. JWT is better for APIs and distributed applications, while Sessions remain an excellent choice for traditional server-rendered websites.
Is JWT secure?
Yes, provided it's implemented correctly with strong secret keys, HTTPS, short-lived Access Tokens, Refresh Tokens, and proper token validation.
Should I store passwords inside JWT?
No. Passwords should never appear inside a JWT payload. Only include non-sensitive information such as the user ID or role.
How long should an Access Token last?
Most applications use an expiration time between 15 and 30 minutes, depending on their security requirements.
Do I always need Refresh Tokens?
Not necessarily. Small applications may only use Access Tokens. However, most production applications use Refresh Tokens to provide a better user experience.
Final Thoughts
Congratulations! You've now learned how JWT Authentication works from both a theoretical and practical perspective.
Starting from the basics, we explored what JWT is, why it's used, how authentication flows work, the structure of a JWT, Access Tokens, Refresh Tokens, Node.js implementation, authentication middleware, route protection, and production security practices.
These concepts form the foundation of authentication systems used in countless modern web applications and REST APIs.










