Secure JWT Authentication in Express.js

Posted by NIYONSHUTI Emmanuel on February 26, 2025

A Beginner’s Guide to Using HTTP-Only Cookies

fig1: jwt authentication flow with httpOnly cookie

When I first started working with authentication in Express.js, managing user sessions felt overwhelming. JSON Web Tokens (JWTs) seemed like a popular choice, but one question kept coming up: Where should the token be stored?

At first, I leaned toward local storage because it seemed simple and intuitive — just store the token and send it with requests. But then I realized this approach had security risks, particularly cross-site scripting (XSS) attacks. Then, I came across HTTP-only cookies, which offered better protection against XSS. However, I soon learned that no method is perfect; cookies come with their own challenges, like cross-site request forgery (CSRF).If you’re interested in a deeper security comparison of both approaches, this blog post breaks it down well.

This guide focuses on implementation: setting up secure JWT authentication in Express.js using HTTP-only cookies. It is geared toward beginners, but assumes you have some familiarity with Node.js and Express, such as installing dependencies and setting up a basic server.

How It Works

When a user logs in, the server generates a JWT containing user information (payload) and signs it using a secret key stored in an environment variable. The token has an expiration time to enhance security since JWTs cannot be easily revoked. The server then stores the JWT inside an HTTP-only cookie, ensuring it is automatically sent with requests without exposing it to JavaScript.

Now, let’s set it up in Express.js

Setting Up the Project

Open your terminal or favorite code editor. First, create a new directory for the project. I’ll name mine jwt-auth, but you can use any name you prefer. Then, navigate into it and initialize a new Node.js project:
# Create the project directory
mkdir jwt-auth
# Move into the project directory
cd jwt-auth
# Initialize a new Node.js project
npm init -y

This will generate a package.json file, which will keep track of your project's dependencies.

Installing dependencies

We need a few packages to build our authentication system: Express, jsonwebtoken and cookie-parser

# Install required dependencies
npm install express jsonwebtoken cookie-parser

Now that our dependencies are ready, let’s create the entry point for our Express app.

Create an index.js file in the project root:

touch index.js

Now, open index.js in your editor and set up a basic Express server. I'll use ES6 import syntax, but you can use CommonJS (require) if you prefer. To enable ES6 imports in Node.js, add this to your package.json:

{
"type": "module"
and we will use --watch, which automatically restarts the server when changes are made. and also, we’ll use --env-file=.env so our environment variables are recognized.
in package.json add these inside scripts object:
"scripts": {
"start": "node index.js",
"dev": "node --watch --env-file=.env index.js"
}
Now, set up the Express server:

import express from 'express';
import cookieParser from 'cookie-parser';


const app = express();

app.use(express.json());
app.use(cookieParser()); // Middleware to parse cookies

app.use("/api/auth", authRoutes);

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

Configuring Environment Variables

Inside the project root, create a .env file and add:


JWT_SECRET=your_secret_key_here #Make sure to use a strong secret key, and never hardcode it in your code

Implementing Authentication

Create JWT utility function

First, create a utils folder inside your project root directory and add a file I will name minegenerateToken.js.

Inside utils/generateToken.js, we will add the following function to generate a JWT.

import jwt from "jsonwebtoken";

const generateToken = (res, userId) => {
const token = jwt.sign(
{ id: user.id },
process.env.JWT_SECRET,
{ expiresIn: "1h"}
);
res.cookie("jwt", token, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 15 * 60 * 1000
})
};

export default generateToken;

This function creates a secure JWT and stores it in an HTTP-only cookie. jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: "1h" })

  • Generates a unique token with the user’s ID.
  • Signed with a secret key to ensure authenticity.
  • Expires in 1 hour, forcing reauthentication after this period to reduce risk if stolen.
  • Storing the Token Securely (res.cookie())
  • httpOnly: true → Prevents JavaScript from accessing the cookie, blocking XSS attacks.
  • secure: process.env.NODE_ENV !== 'development' → Uses HTTPS only in production, ensuring secure transmission.
  • sameSite: 'strict' → Restricts cookies to same-site requests, mitigating CSRF attacks.
  • maxAge: 15 * 60 * 1000 (15 minutes)
  • Defined in milliseconds (15 × 60 × 1000) because JavaScript’s maxAge expects time in milliseconds.
  • Even though the JWT lasts 1 hour, the cookie expires in 15 minutes, limiting exposure if stolen.

Creating Authentication Middleware

Now, let’s create a middleware to verify tokens and extract user information. Inside the middleware folder, create authMiddleware.js and add:

import jwt from "jsonwebtoken";

const protect = (req, res, next) => {
const token = req.cookies.token;
if (!token) return res.status(401).json({ message: "Unauthorized: No token" });

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = { id: decoded.id };
next();
} catch (error) {
res.status(403).json({ message: "Forbidden: Invalid token" });
}
};

export default protect;

This middleware ensures that only authenticated users can access protected routes. It retrieves the JWT from the HTTP-only cookie, verifies it with the secret key, and extracts the user ID. If verification succeeds, req.user is assigned, allowing access to the next middleware or route. If the token is missing or invalid, an appropriate error response is returned.

Setting Up Routes

Create a new file inside the routes folder:

routes/authRoutes.js

import express from "express";
import generateToken from "../utils/generateToken.js";
import protect from "middleware/authMiddleware.js";

const router = express.Router();

// Dummy user for demonstration (in a real app, you'd use a database)
const user = { id: 1, username: "testuser", email: "test@example.com", password: "password123" };

// Login Route
router.post("/login", (req, res) => {
const { email, password } = req.body;

if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}

if (email !== user.email || password !== user.password) {
return res.status(401).json({ message: "Invalid email or password" });
}

const token = generateToken(user.id);
res.cookie("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "Strict",
});

res.json({ message: "Login successful" });
});

// Logout Route
router.post("/logout", (req, res) => {
res.clearCookie("token");
res.json({ message: "Logged out successfully" });
});

router.get("/profile", protect, (req, res) => {
res.json(200).json({user: req.user})
})
export default router;

Now, run the server with:

npm run dev

You now have a working JWT authentication system using HTTP-only cookies, reducing exposure to XSS attacks while keeping authentication simple and secure in Express.js. You can test everything using cURL or an API client like Postman.

For better security, consider adding refresh tokens and handling token expiration properly.

Alright, That’s it! If you’ve read this far, I hope this guide was helpful.

Published on February 26, 2025
Share:

Want to discuss this post?

This post was originally published on Medium.