codelessgenie guide

How to Implement JWT Authentication in Your Backend

Authentication is a cornerstone of secure web applications, ensuring that only authorized users can access protected resources. Among the various authentication methods available, **JSON Web Tokens (JWT)** has emerged as a popular choice due to its simplicity, scalability, and stateless nature. Unlike session-based authentication, which requires the server to store session data, JWT enables stateless authentication by encoding user claims directly into a token. This makes it ideal for distributed systems, microservices, and applications needing to scale horizontally. In this blog, we’ll dive deep into implementing JWT authentication in a backend. We’ll cover everything from understanding how JWT works to building practical endpoints for user registration, login, and protected routes—complete with code examples and security best practices. By the end, you’ll have a working JWT authentication system you can adapt to your own projects.

Table of Contents

  1. Understanding JWT
  2. Prerequisites
  3. Setting Up the Project
  4. User Registration: Storing Users Securely
  5. User Login & JWT Generation
  6. Protecting Routes with JWT Middleware
  7. Refresh Tokens: Enhancing Security
  8. Security Best Practices
  9. Troubleshooting Common Issues
  10. Conclusion
  11. References

1. Understanding JWT

1.1 JWT Structure

A JWT is a compact string formatted as xxxx.yyyy.zzzz, where each segment is base64url-encoded and separated by dots. It consists of three parts:

The header specifies the token type (JWT) and the signing algorithm (e.g., HS256 for HMAC-SHA256 or RS256 for RSA-SHA256). Example:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload

The payload contains “claims”—statements about the user (e.g., user ID, role) and metadata (e.g., expiration time). Claims are not encrypted (only base64-encoded), so avoid sensitive data here. Standard claims include:

  • exp: Expiration time (timestamp).
  • iat: Issued at time (timestamp).
  • sub: Subject (e.g., user ID).
    Example payload:
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}

Signature

The signature ensures the token hasn’t been tampered with. It’s created by hashing the header (base64-encoded), payload (base64-encoded), and a secret key (or private key for RSA) using the algorithm specified in the header:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

1.2 How JWT Authentication Works

  1. User Login: The user submits credentials (e.g., email/password) to the server.
  2. Token Generation: The server validates credentials, then signs and returns a JWT (and optionally a refresh token).
  3. Client Storage: The client stores the JWT (e.g., in localStorage, session storage, or secure cookies).
  4. Protected Requests: For subsequent requests to protected routes, the client sends the JWT in the Authorization header:
    Authorization: Bearer <jwt_token>
  5. Token Verification: The server extracts the JWT, verifies its signature, and checks claims (e.g., expiration). If valid, the request is allowed; otherwise, it’s rejected.

2. Prerequisites

To follow along, you’ll need:

  • Node.js (v14+ recommended) and npm/yarn installed.
  • Basic knowledge of Express.js and REST APIs.
  • A MongoDB database (local or MongoDB Atlas).
  • A code editor (e.g., VS Code).

3. Setting Up the Project

We’ll use Node.js, Express, and MongoDB (with Mongoose ODM) for this tutorial. Let’s initialize the project:

Step 1: Create a Project Folder

mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y

Step 2: Install Dependencies

Install required packages:

npm install express mongoose jsonwebtoken bcryptjs dotenv cors
npm install --save-dev nodemon
  • express: Web framework for building APIs.
  • mongoose: ODM for MongoDB (simplifies database interactions).
  • jsonwebtoken: Library to generate/verify JWTs.
  • bcryptjs: For hashing passwords securely.
  • dotenv: To manage environment variables (e.g., JWT secret).
  • cors: To handle cross-origin requests.

Step 3: Configure Environment Variables

Create a .env file to store sensitive data:

# .env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/jwt-auth-demo  # or your Atlas URI
JWT_SECRET=your_strong_jwt_secret_key_here  # Use a long, random string
JWT_EXPIRES_IN=15m  # Access token expiry (short-lived)
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here  # Separate secret for refresh tokens
REFRESH_TOKEN_EXPIRES_IN=7d  # Refresh token expiry (longer-lived)

Step 4: Set Up the Express Server

Create server.js (the entry point):

// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');

const app = express();

// Middleware
app.use(cors());
app.use(express.json());  // Parse JSON request bodies

// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('MongoDB connected'))
  .catch(err => console.error('MongoDB connection error:', err));

// Basic route to test the server
app.get('/', (req, res) => {
  res.send('JWT Auth Demo API is running!');
});

// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Update package.json to use nodemon for auto-reloading:

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

Start the server:

npm run dev

You should see:

Server running on port 5000
MongoDB connected

4. User Registration: Storing Users Securely

Before implementing JWT, we need a way to register users and store their data securely (never store plaintext passwords!).

Step 1: Create a User Model

Define a User schema with Mongoose. Create models/User.js:

// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required']
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: [6, 'Password must be at least 6 characters']
  },
  refreshTokens: [{  // Store refresh tokens for the user
    token: String,
    expiresAt: Date
  }]
}, { timestamps: true });

// Hash password before saving the user
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();  // Skip if password not changed

  // Hash password with cost factor 12
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// Method to compare password with hashed password
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

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

Step 2: Create a Registration Endpoint

Add a POST /api/register route in server.js to handle user registration:

// server.js (add this after MongoDB connection)
const User = require('./models/User');

// Register user
app.post('/api/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;

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

    // Create new user
    const user = await User.create({
      name,
      email,
      password  // bcrypt will hash this via the pre-save hook
    });

    // Return user data (excluding password)
    res.status(201).json({
      _id: user._id,
      name: user.name,
      email: user.email
    });
  } catch (err) {
    res.status(500).json({ message: 'Server error', error: err.message });
  }
});

Test the Registration Endpoint

Use tools like Postman or curl to send a POST request to http://localhost:5000/api/register with:

{
  "name": "John Doe",
  "email": "[email protected]",
  "password": "password123"
}

You should get a 201 response with user data (password excluded).

5. User Login & JWT Generation

When a user logs in with valid credentials, the server generates a JWT (access token) and a refresh token.

Step 1: Create Login Endpoint

Add a POST /api/login route to validate credentials and issue tokens:

// server.js (add below registration route)
const jwt = require('jsonwebtoken');

// Generate access token
const generateAccessToken = (userId) => {
  return jwt.sign({ sub: userId }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRES_IN
  });
};

// Generate refresh token
const generateRefreshToken = (userId) => {
  return jwt.sign({ sub: userId }, process.env.REFRESH_TOKEN_SECRET, {
    expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN
  });
};

// Login user
app.post('/api/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Find user by email
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    // Verify password
    const isPasswordValid = await user.comparePassword(password);
    if (!isPasswordValid) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    // Generate tokens
    const accessToken = generateAccessToken(user._id);
    const refreshToken = generateRefreshToken(user._id);

    // Store refresh token in the database (with expiry)
    const refreshTokenExpiry = new Date();
    refreshTokenExpiry.setDate(refreshTokenExpiry.getDate() + 7); // 7 days

    user.refreshTokens.push({
      token: refreshToken,
      expiresAt: refreshTokenExpiry
    });
    await user.save();

    // Send tokens to client
    res.json({
      accessToken,
      refreshToken,
      user: {
        _id: user._id,
        name: user.name,
        email: user.email
      }
    });
  } catch (err) {
    res.status(500).json({ message: 'Server error', error: err.message });
  }
});

Test the Login Endpoint

Send a POST request to http://localhost:5000/api/login with:

{
  "email": "[email protected]",
  "password": "password123"
}

You’ll receive an accessToken, refreshToken, and user data.

6. Protecting Routes with JWT Middleware

To restrict access to sensitive routes (e.g., user profile), create middleware to verify the JWT from the Authorization header.

Step 1: Create JWT Verification Middleware

Add a middleware function to server.js to check for valid JWTs:

// server.js (add this as a utility function)
const verifyAccessToken = (req, res, next) => {
  try {
    // Get token from Authorization header
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ message: 'Access denied. No token provided.' });
    }

    const token = authHeader.split(' ')[1]; // Extract token after "Bearer "

    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = decoded.sub; // Attach user ID to request object
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ message: 'Token expired' });
    }
    res.status(401).json({ message: 'Invalid token' });
  }
};

Step 2: Create a Protected Route

Add a GET /api/profile route that requires a valid JWT:

// server.js (add below login route)
// Protected route: Get user profile
app.get('/api/profile', verifyAccessToken, async (req, res) => {
  try {
    const user = await User.findById(req.userId).select('-password -refreshTokens'); // Exclude sensitive fields
    if (!user) {
      return res.status(404).json({ message: 'User not found' });
    }
    res.json(user);
  } catch (err) {
    res.status(500).json({ message: 'Server error' });
  }
});

Test the Protected Route

Send a GET request to http://localhost:5000/api/profile with the JWT in the Authorization header:

Authorization: Bearer <your_access_token>

If the token is valid, you’ll get the user’s profile data. If invalid/expired, you’ll get a 401 error.

7. Refresh Tokens: Enhancing Security

Access tokens should be short-lived (e.g., 15 minutes) to minimize risk if leaked. When they expire, clients use a refresh token (longer-lived) to request a new access token.

Step 1: Create Refresh Token Endpoint

Add a POST /api/refresh-token route to issue new access tokens:

// server.js (add below profile route)
app.post('/api/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      return res.status(400).json({ message: 'Refresh token required' });
    }

    // Verify refresh token
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    const userId = decoded.sub;

    // Check if refresh token exists in the database
    const user = await User.findById(userId);
    if (!user) {
      return res.status(401).json({ message: 'Invalid refresh token' });
    }

    const storedToken = user.refreshTokens.find(
      rt => rt.token === refreshToken && rt.expiresAt > new Date()
    );
    if (!storedToken) {
      return res.status(401).json({ message: 'Refresh token expired or invalid' });
    }

    // Generate new access token
    const newAccessToken = generateAccessToken(userId);
    res.json({ accessToken: newAccessToken });
  } catch (err) {
    res.status(401).json({ message: 'Invalid refresh token', error: err.message });
  }
});

Step 2: Add Logout Endpoint (Invalidate Refresh Tokens)

To log out, remove the refresh token from the database:

// server.js (add below refresh-token route)
app.post('/api/logout', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      return res.status(400).json({ message: 'Refresh token required' });
    }

    // Remove refresh token from user's array
    await User.findOneAndUpdate(
      { 'refreshTokens.token': refreshToken },
      { $pull: { refreshTokens: { token: refreshToken } } }
    );

    res.json({ message: 'Logged out successfully' });
  } catch (err) {
    res.status(500).json({ message: 'Server error' });
  }
});

8. Security Best Practices

To secure your JWT implementation:

  1. Use HTTPS: Always transmit tokens over HTTPS to prevent interception.
  2. Store Tokens Securely:
    • Use HttpOnly and Secure cookies for tokens (instead of localStorage, which is vulnerable to XSS).
    • Example cookie setup:
      res.cookie('accessToken', token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
        maxAge: 15 * 60 * 1000 // 15 minutes
      });
  3. Short-Lived Access Tokens: Use exp claim (e.g., 15 minutes) to limit exposure if leaked.
  4. Rotate Refresh Tokens: Invalidate old refresh tokens when issuing new ones to prevent replay attacks.
  5. Keep Secrets Secure: Store JWT_SECRET and REFRESH_TOKEN_SECRET in environment variables (never hardcode!).
  6. Validate Claims: Always check exp and iat claims to ensure tokens are not expired or issued prematurely.
  7. Avoid Sensitive Data in Payload: JWT payloads are base64-encoded, not encrypted.

9. Troubleshooting Common Issues

IssueSolution
TokenExpiredErrorCheck exp claim; use refresh tokens to get a new access token.
JsonWebTokenError: invalid signatureEnsure the same JWT_SECRET is used for signing and verification.
No token providedVerify the client sends the Authorization: Bearer <token> header.
User not found in protected routesEnsure req.userId is correctly set in middleware and the user exists.

10. Conclusion

JWT authentication provides a stateless, scalable way to secure your backend APIs. By following this guide, you’ve learned to:

  • Register users and hash passwords securely.
  • Generate and verify JWT access tokens.
  • Protect routes with middleware.
  • Implement refresh tokens for seamless user sessions.
  • Apply security best practices to mitigate risks.

With these tools, you can build robust authentication systems for your applications.

11. References