Secure Authentication with JWT in Node.js: A Comprehensive Guide

Secure Authentication with JWT in Node.js: A Comprehensive Guide

Introduction

In today’s digital landscape, security is paramount. Whether you’re building a simple app or a large-scale system, securing user data is critical. JSON Web Tokens (JWT) are a popular method for handling authentication in modern web applications. In this blog, we will explore how to implement secure authentication using JWT in a Node.js application, including handling authentication on the client side. We’ll break down every step with clear explanations and code examples.


What is JWT?

JWT stands for JSON Web Token. It is a compact, URL-safe token that can be used to represent claims between two parties: the client and the server. JWTs are commonly used for authentication and authorization purposes.

Structure of a JWT

A JWT consists of three parts:

  1. Header: Contains metadata about the token, such as the type (JWT) and the signing algorithm (e.g., HMAC SHA256).

  2. Payload: Contains the claims, which are the information being exchanged (e.g., user ID).

  3. Signature: A unique string created by encoding the header and payload and signing them with a secret key.

The structure looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTYyMjEyMzkwMH0.c3A1LgSZYPgYJQn7skx9uANr6LUhfZp4d5lQ2wCBO8Y

Setting Up a Node.js Project

First, let’s set up a basic Node.js project. If you haven’t already, make sure you have Node.js installed.

  1. Initialize the project:

     mkdir jwt-auth-node
     cd jwt-auth-node
     npm init -y
    
  2. Install dependencies: We’ll need several packages:

    • express: For handling HTTP requests.

    • jsonwebtoken: For creating and verifying JWTs.

    • bcryptjs: For hashing passwords.

    • dotenv: For managing environment variables.

    • mongoose: For interacting with MongoDB (if using MongoDB as a database).

Install them by running:

    npm install express jsonwebtoken bcryptjs dotenv mongoose
  1. Create the basic structure:

     ├── server.js
     ├── .env
     └── models
         └── User.js
    

Step 1: Creating the User Model

In this example, we’ll use MongoDB to store user data, and Mongoose will help us interact with the database.

models/User.js:

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

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

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

UserSchema.methods.comparePassword = async function (password) {
  return await bcrypt.compare(password, this.password);
};

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

Step 2: Setting Up Authentication Routes

Next, we’ll create routes for user registration, login, and accessing protected resources.

server.js:

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

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

mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true });

app.post('/register', async (req, res) => {
  try {
    const { username, password } = req.body;
    const user = new User({ username, password });
    await user.save();
    res.status(201).json({ message: 'User registered successfully' });
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;
    const user = await User.findOne({ username });
    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Generate JWT
    const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' });
    res.json({ token });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/protected', authenticateToken, (req, res) => {
  res.json({ message: 'This is a protected route' });
});

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.sendStatus(401);

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.sendStatus(403);
    req.user = user;
    next();
  });
}

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Explanation:

  • /register: Registers a new user. The password is hashed before saving it to the database.

  • /login: Authenticates the user and generates a JWT.

  • /protected: A protected route that can only be accessed with a valid JWT.

Step 3: Handling Authentication on the Client Side

On the client side, handling JWTs typically involves storing the token after a successful login and attaching it to requests that require authentication.

Here’s an example using plain JavaScript:

// Login function
async function login(username, password) {
  const response = await fetch('http://localhost:3000/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });

  const data = await response.json();
  if (response.ok) {
    localStorage.setItem('token', data.token);
    console.log('Login successful');
  } else {
    console.error(data.error);
  }
}

// Accessing a protected route
async function accessProtectedRoute() {
  const token = localStorage.getItem('token');
  const response = await fetch('http://localhost:3000/protected', {
    headers: { 'Authorization': `Bearer ${token}` }
  });

  if (response.ok) {
    const data = await response.json();
    console.log('Protected data:', data);
  } else {
    console.error('Failed to access protected route');
  }
}

Step 4: Token Expiry and Refreshing Tokens

JWT tokens typically have an expiration time. To maintain a user’s session without forcing them to log in repeatedly, you can implement token refreshing.

Refresh Token Flow:

  • Generate a short-lived JWT (e.g., 15 minutes) and a long-lived refresh token (e.g., 7 days).

  • Store the refresh token securely (usually as an HttpOnly cookie).

  • When the JWT expires, use the refresh token to obtain a new JWT.

Here’s a basic implementation:

const refreshTokens = [];

app.post('/token', (req, res) => {
  const { token } = req.body;
  if (!token || !refreshTokens.includes(token)) return res.sendStatus(403);

  jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
    if (err) return res.sendStatus(403);

    const accessToken = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '15m' });
    res.json({ accessToken });
  });
});

app.post('/login', async (req, res) => {
  // existing code ...

  const refreshToken = jwt.sign({ userId: user._id }, process.env.REFRESH_TOKEN_SECRET);
  refreshTokens.push(refreshToken);
  res.json({ token, refreshToken });
});

Step 5: Best Practices for JWT Security

  1. Store JWTs securely: Avoid storing JWTs in localStorage if possible. Use HttpOnly cookies for better security.

  2. Use short-lived tokens: Set a short expiration time for access tokens and implement token refreshing.

  3. Use HTTPS: Always use HTTPS to prevent token interception.

  4. Implement token revocation: Maintain a blacklist of tokens that should be considered invalid (e.g., after a user logs out).


Conclusion

JWT is a powerful tool for handling authentication in modern web applications. This blog covered everything from setting up a Node.js server with JWT authentication to handling tokens on the client side. By following these steps, you can implement secure and scalable authentication in your applications. Remember, security is an ongoing process, so keep your methods and practices up to date with the latest security standards.

Happy coding!