How Does JWT Authentication Work?

First, it is important to understand the flow of JWT authentication:

  1. The user logs in by entering their email address and password.
  2. The server verifies the login credentials in the database.
  3. If everything is correct, the server generates a JWT token.
  4. This token is sent to the client.
  5. The client includes this token in the "Authorization" header with every request.
  6. The server verifies the token and returns a response.

💡 This entire process is stateless — meaning the server does not need to store any sessions.

Step-by-Step Implementation

Install Project Configuration and Dependencies

First, create a new Node.js project and install the necessary packages.

mkdir jwt-auth-api && cd jwt-auth-api npm init -y npm install express jsonwebtoken bcryptjs dotenv cookie-parser npm install -D nodemon

The following packages are required:

Package Purpose
express For setting up the server
jsonwebtoken For generating and verifying JWTs
bcryptjs For hashing passwords
dotenv For managing environment variables
cookie-parser For processing cookies

Set Up Environment Variables

Create a .env file in your project:

PORT=5000 JWT_SECRET=a3F$k9#mP2@nQ8!xZ5&wR7*vT1^yU6%sL4jD0 JWT_REFRESH_SECRET=x7K#pL2@mN9!qR4&vT8*wS5^yU3%sM1jE6b JWT_EXPIRES_IN=15m JWT_REFRESH_EXPIRES_IN=7d

🔐 Security note: Never use a simple key for the JWT_SECRET value. Instead, use a strong random key. You can generate a secure key with a single click at jwtsecretkeygenerator.com.

And don\'t forget to add the following to your .gitignore file:

.env node_modules/

Create a Function to Generate the Token

const jwt = require(\'jsonwebtoken\'); require(\'dotenv\').config(); function generateAccessToken(user) { return jwt.sign( { id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN } ); } function generateRefreshToken(user) { return jwt.sign( { id: user.id }, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN } ); }

⏱️ Security measure: Always set a short validity period for the access token—for example, 15 minutes. The refresh token can have a longer validity period, such as 7 days. This minimises the risk of damage even if the access token is stolen.

Create Registration and Login Routes

const express = require(\'express\'); const bcrypt = require(\'bcryptjs\'); const app = express(); app.use(express.json()); // Fake database (use MongoDB or PostgreSQL in real app) const users = []; // Registration Route app.post(\'/register\', async (req, res) => { try { const { email, password } = req.body; const existingUser = users.find(u => u.email === email); if (existingUser) { return res.status(400).json({ message: \'User already exists\' }); } const hashedPassword = await bcrypt.hash(password, 12); const user = { id: Date.now(), email, password: hashedPassword }; users.push(user); res.status(201).json({ message: \'User registered successfully\' }); } catch (error) { res.status(500).json({ message: \'Server error\' }); } }); // Login Route app.post(\'/login\', async (req, res) => { try { const { email, password } = req.body; const user = users.find(u => u.email === email); if (!user) { return res.status(401).json({ message: \'Invalid credentials\' }); } const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return res.status(401).json({ message: \'Invalid credentials\' }); } const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); // Send the refresh token in an HTTP-only cookie res.cookie(\'refreshToken\', refreshToken, { httpOnly: true, secure: true, sameSite: \'Strict\', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days }); res.json({ accessToken }); } catch (error) { res.status(500).json({ message: \'Server error\' }); } });

🔑 Security measure 1: When hashing passwords with bcrypt, make sure that the number of salt rounds is at least 10 to 12.

🍪 Security measure 2: Store the refresh token in an HTTP-only cookie rather than in localStorage. This protects against XSS attacks.

🚫 Security measure 3: If a login attempt fails, always display a general message such as "Invalid login credentials"—never specify that the email address or password is incorrect.

Create Authentication Middleware

const jwt = require(\'jsonwebtoken\'); function authenticateToken(req, res, next) { const authHeader = req.headers[\'authorization\']; const token = authHeader && authHeader.split(\' \')[1]; // Bearer TOKEN if (!token) { return res.status(401).json({ message: \'Access denied. No token provided.\' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: [\'HS256\'] // explicitly specify algorithm }); req.user = decoded; next(); } catch (error) { return res.status(403).json({ message: \'Invalid or expired token\' }); } }

⚠️ Security note: Always specify algorithms explicitly in jwt.verify(). Otherwise, an attacker could bypass token verification by setting the algorithm to "none"—this is a known JWT vulnerability.

Create a Protected Route

Protected route — only authenticated users can access:

app.get(\'/profile\', authenticateToken, (req, res) => { res.json({ message: \'Welcome to your profile!\', user: req.user }); });

Create a Refresh Token Route

const cookieParser = require(\'cookie-parser\'); app.use(cookieParser()); app.post(\'/refresh-token\', (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) { return res.status(401).json({ message: \'Refresh token not found\' }); } try { const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, { algorithms: [\'HS256\'] }); const newAccessToken = generateAccessToken({ id: decoded.id, email: decoded.email }); res.json({ accessToken: newAccessToken }); } catch (error) { return res.status(403).json({ message: \'Invalid refresh token\' }); } });

Create a Logout Route

app.post(\'/logout\', (req, res) => { res.clearCookie(\'refreshToken\', { httpOnly: true, secure: true, sameSite: \'Strict\' }); res.json({ message: \'Logged out successfully\' }); });

Extra Security Fixes That Are Necessary in Production

🛡️ 1. Enable Rate Limiting

Make sure that rate limiting is enabled on the login route to prevent brute-force attacks.

npm install express-rate-limit const rateLimit = require(\'express-rate-limit\'); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts only message: \'Too many login attempts. Please try again after 15 minutes.\' }); app.post(\'/login\', loginLimiter, async (req, res) => { ... });

🔒 2. Use HTTPS

Always use HTTPS in production. Tokens can be intercepted over HTTP.

⛑️ 3. Install Helmet.js

npm install helmet const helmet = require(\'helmet\'); app.use(helmet());

This automatically adds several security headers.

🚨 4. Protect Against the "none" Algorithm Attack

Always specify the algorithm array in jwt.verify() as shown in Step 5 above.

Frequently Asked Questions

What is the difference between an access token and a refresh token?

An access token is short-lived (e.g. 15 minutes) and is used to authenticate API requests. A refresh token is long-lived (e.g. 7 days) and is used to generate new access tokens without requiring the user to log in again. The refresh token should be stored in an HTTP-only cookie.

Why should I store the refresh token in an HTTP-only cookie instead of localStorage?

localStorage is accessible via JavaScript, which makes it vulnerable to XSS (Cross-Site Scripting) attacks. An HTTP-only cookie cannot be read by JavaScript, which significantly reduces the attack surface.

Why should I specify the algorithm explicitly in jwt.verify()?

If you do not specify the algorithm, an attacker could forge a token by setting the algorithm to "none", which bypasses signature verification entirely. Always pass algorithms: [\'HS256\'] (or your chosen algorithm) in the verify options.

What does Helmet.js do for a Node.js application?

Helmet.js sets various HTTP security headers automatically, such as X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy. These headers protect against common web vulnerabilities like clickjacking, MIME-type sniffing, and XSS attacks.

Conclusion

To build a production-ready JWT authentication system, simply generating a token is not enough. Here are the factors you need to consider:

  • Use a strong secret key — generate it using jwtsecretkeygenerator.com
  • Set a short expiration time for the access token (15 minutes)
  • Store the refresh token in an HTTP-only cookie
  • Explicitly specify the algorithm in jwt.verify()
  • Hash passwords with bcrypt
  • Enforce a rate limit
  • Use HTTPS and Helmet.js

If you follow these steps, you can create an authentication system that not only works but also protects you from real-world attacks. Small security flaws can turn into big problems later on—so get it right from the start.