JWT Authentication in Node.js: A Complete Beginner's Guide

Learn JWT Authentication in Node.js with simple examples. Build a mini auth system with register, login, and protected routes explained step by step.
Introduction: Why Authentication Matters
Imagine you're building a house. You wouldn't leave your front door wide open for anyone to walk in, right? The same principle applies to web applications. Authentication is your digital door lock – it ensures only the right people can access specific parts of your app.
In web development, authentication is the process of verifying who a user is. Whether it's a social media platform, banking app, or even a simple blog, most applications need to:
Identify users (who are you?)
Control access to resources (what can you see/do?)
Maintain user sessions (stay logged in)
There are several ways to handle authentication:
Session-based authentication (traditional approach using server-side sessions)
Cookie-based authentication (storing user data in browser cookies)
JWT Authentication (our focus today - using JSON Web Tokens)
Today, we'll dive deep into JWT authentication in Node.js and build a complete authentication system from scratch.
What is JWT? (Think of it as a Digital Passport)
JWT (JSON Web Token) is like a digital passport for your web application. Just as a passport contains your personal information and proves your identity when traveling, a JWT contains user information and proves their identity when accessing your app.
Here's the beautiful thing about JWTs: they're self-contained. Unlike traditional sessions where the server needs to store user data, JWTs carry all the necessary information within themselves.
JWT Structure: Three Parts, One Token
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It consists of three parts separated by dots:
Header - Contains metadata about the token
Payload - Contains the actual user data
Signature - Ensures the token hasn't been tampered with
Think of it like a tamper-proof envelope:
The header tells you what type of envelope it is
The payload contains the letter inside
The signature is the wax seal that proves no one opened it
JWT vs Sessions: Quick Comparison
| JWT | Sessions |
| Stateless (server doesn't store anything) | Stateful (server stores session data) |
| Great for microservices & APIs | Traditional approach |
| Token stored on client side | Session ID stored on client, data on server |
| Self-contained | Requires server lookup |
Building JWT Authentication: Step by Step
Let's build a complete authentication system! We'll create a Node.js app with user registration, login, and protected routes.
Step 1: Project Setup
First, let's set up our project:
mkdir jwt-auth-tutorial
cd jwt-auth-tutorial
npm init -y
npm install express bcryptjs jsonwebtoken dotenv
npm install --save-dev nodemon
Create the following file structure:
jwt-auth-tutorial/
├── server.js
├── .env
└── package.json
Step 2: Environment Variables (.env file)
PORT=5000
JWT_SECRET=your_super_secret_jwt_key_here_make_it_long_and_random
Important: In production, use a strong, random secret key. Never commit this to version control!
Step 3: Basic Server Setup (server.js)
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const app = express();
// Middleware to parse JSON
app.use(express.json());
// In-memory user storage (use a database in production!)
let users = [];
// Helper function to find user by email
const findUserByEmail = (email) => {
return users.find((user) => user.email === email);
};
// Helper function to find user by ID
const findUserById = (id) => {
return users.find((user) => user.id === id);
};
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Step 4: User Registration
// USER REGISTRATION ENDPOINT
app.post("/api/register", async (req, res) => {
try {
const { name, email, password } = req.body;
// Basic validation
if (!name || !email || !password) {
return res.status(400).json({
message: "Please provide name, email, and password",
});
}
// Check if user already exists
if (findUserByEmail(email)) {
return res.status(400).json({
message: "User already exists with this email",
});
}
// Hash the password (salt rounds = 10)
const hashedPassword = await bcrypt.hash(password, 10);
// Create new user
const newUser = {
id: users.length + 1, // Simple ID generation
name,
email,
password: hashedPassword,
};
// Save user to our "database"
users.push(newUser);
res.status(201).json({
message: "User registered successfully",
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
// Never send password back!
},
});
} catch (error) {
console.error("Registration error:", error);
res.status(500).json({ message: "Server error" });
}
});
Step 5: User Login (JWT Generation)
// USER LOGIN ENDPOINT
app.post("/api/login", async (req, res) => {
try {
const { email, password } = req.body;
// Basic validation
if (!email || !password) {
return res.status(400).json({
message: "Please provide email and password",
});
}
// Find user by email
const user = findUserByEmail(email);
if (!user) {
return res.status(400).json({
message: "Invalid credentials",
});
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(400).json({
message: "Invalid credentials",
});
}
// Create JWT payload
const payload = {
user: {
id: user.id,
email: user.email,
},
};
// Generate JWT token
const token = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: "1h" } // Token expires in 1 hour
);
res.json({
message: "Login successful",
token,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
} catch (error) {
console.error("Login error:", error);
res.status(500).json({ message: "Server error" });
}
});
Step 6: JWT Verification Middleware
This is where the magic happens! This middleware will run before any protected route to verify the JWT:
// JWT VERIFICATION MIDDLEWARE
const authenticateToken = (req, res, next) => {
// Get token from header
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
// Check if token exists
if (!token) {
return res.status(401).json({
message: "Access token required",
});
}
// Verify token
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({
message: "Invalid or expired token",
});
}
// Token is valid, save user info to request object
req.user = decoded.user;
next(); // Continue to the next middleware/route
});
};
Step 7: Protected Routes
Now let's create some protected routes that require authentication:
// PROTECTED ROUTE - Get user profile
app.get("/api/profile", authenticateToken, (req, res) => {
// req.user is available because of our middleware
const user = findUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
res.json({
message: "Profile data retrieved successfully",
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
});
// PROTECTED ROUTE - Get all users (admin-like functionality)
app.get("/api/users", authenticateToken, (req, res) => {
// Return all users without passwords
const safeUsers = users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
}));
res.json({
message: "Users retrieved successfully",
users: safeUsers,
});
});
// PUBLIC ROUTE - Anyone can access
app.get("/api/public", (req, res) => {
res.json({
message: "This is a public endpoint. No authentication required!",
});
});
Complete Server Code
Here's the complete server.js file:
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const app = express();
// Middleware
app.use(express.json());
// In-memory user storage (use a database in production!)
let users = [];
// Helper functions
const findUserByEmail = (email) => users.find((user) => user.email === email);
const findUserById = (id) => users.find((user) => user.id === id);
// JWT Verification Middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "Access token required" });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ message: "Invalid or expired token" });
}
req.user = decoded.user;
next();
});
};
// Routes
app.post("/api/register", async (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res
.status(400)
.json({ message: "Please provide name, email, and password" });
}
if (findUserByEmail(email)) {
return res
.status(400)
.json({ message: "User already exists with this email" });
}
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = {
id: users.length + 1,
name,
email,
password: hashedPassword,
};
users.push(newUser);
res.status(201).json({
message: "User registered successfully",
user: { id: newUser.id, name: newUser.name, email: newUser.email },
});
} catch (error) {
console.error("Registration error:", error);
res.status(500).json({ message: "Server error" });
}
});
app.post("/api/login", async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res
.status(400)
.json({ message: "Please provide email and password" });
}
const user = findUserByEmail(email);
if (!user) {
return res.status(400).json({ message: "Invalid credentials" });
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(400).json({ message: "Invalid credentials" });
}
const payload = {
user: {
id: user.id,
email: user.email,
},
};
const token = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: "1h",
});
res.json({
message: "Login successful",
token,
user: { id: user.id, name: user.name, email: user.email },
});
} catch (error) {
console.error("Login error:", error);
res.status(500).json({ message: "Server error" });
}
});
app.get("/api/profile", authenticateToken, (req, res) => {
const user = findUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
res.json({
message: "Profile data retrieved successfully",
user: { id: user.id, name: user.name, email: user.email },
});
});
app.get("/api/users", authenticateToken, (req, res) => {
const safeUsers = users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
}));
res.json({
message: "Users retrieved successfully",
users: safeUsers,
});
});
app.get("/api/public", (req, res) => {
res.json({
message: "This is a public endpoint. No authentication required!",
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Testing Your Authentication System
Let's test our JWT authentication system using a tool like Postman or curl:
1. Register a User
POST http://localhost:5000/api/register
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"password": "mypassword123"
}
2. Login
POST http://localhost:5000/api/login
Content-Type: application/json
{
"email": "john@example.com",
"password": "mypassword123"
}
This will return a token. Copy it!
3. Access Protected Route
GET http://localhost:5000/api/profile
Authorization: Bearer YOUR_JWT_TOKEN_HERE
JWT Authentication Flow Diagram
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │ │ Server │ │ Database │
│ (Browser) │ │ │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
│ 1. POST /login │ │
│ (email/password) │ │
├──────────────────►│ │
│ │ 2. Verify user │
│ ├──────────────────►│
│ │ │
│ │ 3. User data │
│ │◄──────────────────┤
│ │ │
│ │ 4. Generate JWT │
│ │ (sign token) │
│ │ │
│ 5. Return JWT │ │
│◄──────────────────┤ │
│ │ │
│ 6. Store JWT │ │
│ (localStorage/ │ │
│ sessionStorage│ │
│ or cookie) │ │
│ │ │
│ 7. GET /profile │ │
│ Header: Bearer JWT│ │
├──────────────────►│ │
│ │ 8. Verify JWT │
│ │ (check signature│
│ │ & expiration) │
│ │ │
│ 9. Protected data │ │
│◄──────────────────┤ │
Best Practices & Security Tips
1. JWT Secret Management
// ❌ DON'T DO THIS
const JWT_SECRET = "mysecret";
// ✅ DO THIS
const JWT_SECRET = process.env.JWT_SECRET; // Long, random string
2. Password Security
// ✅ Always hash passwords
const hashedPassword = await bcrypt.hash(password, 10);
// ✅ Use strong salt rounds (10-12 is good)
const saltRounds = 12;
3. Token Expiration
// ✅ Set reasonable expiration times
const token = jwt.sign(payload, JWT_SECRET, {
expiresIn: "15m", // Short-lived access tokens
});
4. What NOT to Store in JWT
// ❌ DON'T store sensitive data
const payload = {
userId: user.id,
password: user.password, // NEVER!
creditCard: user.cc, // NEVER!
};
// ✅ Store minimal, non-sensitive data
const payload = {
user: {
id: user.id,
email: user.email,
role: user.role,
},
};
Bonus: Access Tokens vs Refresh Tokens
For production applications, you should implement a two-token system:
Access Token vs Refresh Token
| Access Token | Refresh Token |
| Short-lived (5-15 minutes) | Long-lived (7-30 days) |
| Used for API requests | Used to get new access tokens |
| Stored in memory or short-term storage | Stored securely (httpOnly cookie) |
| Contains user permissions | Contains minimal user info |
Refresh Token Flow Diagram
┌─────────────┐ ┌──────────────┐
│ Client │ │ Server │
└─────────────┘ └──────────────┘
│ │
│ 1. Login Request │
├──────────────────►│
│ │
│ 2. Access Token + │
│ Refresh Token │
│◄──────────────────┤
│ │
│ 3. API Requests │
│ (with Access Token)│
├──────────────────►│
│ │
│ 4. Access Token │
│ Expires │
│ │
│ 5. Use Refresh │
│ Token to get │
│ new Access Token│
├──────────────────►│
│ │
│ 6. New Access Token│
│◄──────────────────┤
│ │
│ 7. Continue API │
│ Requests │
├──────────────────►│
Implementation Example
// Generate both tokens
const generateTokens = (user) => {
const payload = { user: { id: user.id, email: user.email } };
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: "15m",
});
const refreshToken = jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: "7d",
});
return { accessToken, refreshToken };
};
// Refresh token endpoint
app.post("/api/refresh", (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: "Refresh token required" });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ message: "Invalid refresh token" });
}
const user = findUserById(decoded.user.id);
const { accessToken } = generateTokens(user);
res.json({ accessToken });
});
});
JWT vs Sessions: When to Use What?
Use JWT When:
Building APIs or microservices
Need stateless authentication
Working with mobile apps
Implementing single sign-on (SSO)
Scaling across multiple servers
Use Sessions When:
Building traditional web applications
Need server-side session management
Require immediate token invalidation
Working with server-side rendered apps
Common Mistakes to Avoid
1. Storing JWT in localStorage
// ❌ Vulnerable to XSS attacks
localStorage.setItem("token", jwt);
// ✅ Better: Use httpOnly cookies for web apps
res.cookie("token", jwt, {
httpOnly: true,
secure: true,
sameSite: "strict",
});
2. Not Handling Token Expiration
// ❌ No expiration handling
fetch("/api/profile", {
headers: { Authorization: `Bearer ${token}` },
});
// ✅ Handle expired tokens
fetch("/api/profile", {
headers: { Authorization: `Bearer ${token}` },
}).then((response) => {
if (response.status === 403) {
// Token expired, redirect to login
window.location.href = "/login";
}
return response.json();
});
3. Weak JWT Secrets
// ❌ Weak secret
const JWT_SECRET = "secret";
// ✅ Strong secret (at least 256 bits)
const JWT_SECRET =
"your-super-long-and-random-secret-key-that-is-at-least-256-bits-long";
Performance Considerations
JWT Pros:
Stateless: No server-side storage needed
Scalable: Works great with load balancers
Fast: No database lookup for each request
Cross-domain: Perfect for microservices
JWT Cons:
Size: Larger than session IDs
Immutable: Can't revoke tokens easily
Storage: Client needs to store the token securely
Real-World Production Tips
1. Database Integration
Replace our in-memory users array with a real database:
// Example with MongoDB/Mongoose
const User = require("./models/User");
const findUserByEmail = async (email) => {
return await User.findOne({ email });
};
const createUser = async (userData) => {
const user = new User(userData);
return await user.save();
};
2. Rate Limiting
Add rate limiting to prevent brute force attacks:
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: "Too many login attempts, please try again later",
});
app.post("/api/login", loginLimiter, async (req, res) => {
// login logic
});
3. CORS Configuration
const cors = require("cors");
app.use(
cors({
origin: process.env.FRONTEND_URL,
credentials: true,
})
);
Summary: Why Choose JWT?
JWT authentication offers several compelling benefits for modern web applications:
Security: JWTs are cryptographically signed, making them tamper-proof. The signature ensures that the token hasn't been modified in transit.
Stateless: Unlike traditional sessions, JWTs don't require server-side storage. This makes your application more scalable and reduces server memory usage.
Flexibility: JWTs work seamlessly across different domains and are perfect for microservices architecture. You can use the same token to authenticate across multiple services.
Performance: No database lookups needed for each request validation, making your API faster and more responsive.
Mobile-Friendly: JWTs work great with mobile apps and single-page applications where traditional session cookies might be problematic.
Next Steps
Now that you understand JWT authentication in Node.js, here are some areas to explore further:
Database Integration: Replace in-memory storage with MongoDB, PostgreSQL, or your preferred database
OAuth Integration: Learn how to implement social login (Google, GitHub, Facebook)
Session-Based Authentication: Compare with traditional session-based approaches
Advanced Security: Implement refresh tokens, token blacklisting, and rate limiting
Frontend Integration: Connect your JWT backend with React, Vue, or Angular applications
Testing: Write comprehensive tests for your authentication system
TL;DR - Quick Summary
JWT (JSON Web Token) is a secure, self-contained way to authenticate users in Node.js applications. Here's what we covered:
✅ What JWT is: A digital passport containing user info that proves identity ✅ Key Benefits: Stateless, scalable, secure, and perfect for APIs ✅ Implementation: Built complete registration, login, and protected routes ✅ Security: Password hashing with bcrypt, JWT signing with secrets ✅ Best Practices: Token expiration, secure storage, minimal payload data ✅ Testing: How to test your authentication endpoints
Quick Start Checklist:
✅ Install:
express,bcryptjs,jsonwebtoken,dotenv✅ Set up environment variables (JWT_SECRET)
✅ Create registration endpoint with password hashing
✅ Create login endpoint that returns JWT
✅ Create middleware to verify JWTs
✅ Protect routes using the middleware
✅ Test all endpoints
The complete code example shows a working authentication system that you can use as a foundation for your own projects. Remember to replace the in-memory storage with a real database and implement additional security measures for production use.
JWT authentication strikes an excellent balance between security, performance, and developer experience, making it a popular choice for modern web applications and APIs.
Found this helpful? Follow me for more Node.js tutorials and web development tips! 🚀



