Blog Post

Secure User Authentication with JWT in a Node.js API

Handling user authentication correctly is the most critical part of building a modern backend. If you store passwords in plain text or use insecure session mechanisms, your users' data is immediately at risk. In modern stateless architectures, JSON Web Tokens (JWT) have become the industry standard for securing APIs.

In this tutorial, we will build a secure authentication flow in Node.js/Express, covering password hashing with `bcrypt` and token generation with `jsonwebtoken`.

Step 1: Registration and Password Hashing

Rule number one of backend security: Never store passwords in plain text. We use `bcrypt` to mathematically hash the password before saving it to the database.

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const router = express.Router();
// Fake database for demonstration
const usersDB = []; 

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

  // 1. Generate a salt (adds randomness) and hash the password
  const saltRounds = 10;
  const hashedPassword = await bcrypt.hash(password, saltRounds);

  // 2. Save to database
  const newUser = { id: Date.now(), username, password: hashedPassword };
  usersDB.push(newUser);

  res.status(201).json({ message: "User registered successfully!" });
});

Step 2: Login and Issuing the JWT

When the user tries to log in, we fetch their hashed password from the DB and compare it to their input. If it matches, we sign a JWT. The JWT acts as a digital passport, proving the user's identity to our server for future requests.

router.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = usersDB.find(u => u.username === username);

  if (!user) return res.status(400).json({ error: "User not found" });

  // 1. Compare the provided password with the hashed password
  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) return res.status(401).json({ error: "Invalid password" });

  // 2. Create the JWT payload
  const payload = { userId: user.id, username: user.username };

  // 3. Sign the token with a secret key (Store this in .env in production!)
  const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '1h' });

  res.json({ token });
});

Step 3: Securing Protected Routes with Middleware

Now that the client has a token, they must send it in the Authorization: Bearer <token> header for any request that requires login. We create a middleware function to intercept these requests and verify the signature of the token.

// The JWT Verification Middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Extract from "Bearer TOKEN"

  if (!token) return res.status(401).json({ error: "Access denied" });

  jwt.verify(token, process.env.JWT_SECRET, (err, decodedUser) => {
    if (err) return res.status(403).json({ error: "Invalid or expired token" });
    
    // Attach the decoded user payload to the request object
    req.user = decodedUser; 
    next(); // Proceed to the actual route
  });
}

// Protecting a specific route
router.get('/profile', authenticateToken, (req, res) => {
  // We know req.user is safe because the middleware verified the token!
  res.json({ message: `Welcome to your profile, ${req.user.username}!` });
});

Security Best Practices

While JWTs are powerful, they must be handled with care. Never put sensitive data (like a credit card number) inside the payload, as JWTs are merely encoded (Base64), not encrypted. Additionally, for maximum security against XSS attacks, modern frontends should store these tokens in HttpOnly Cookies rather than LocalStorage.