How Does JWT Authentication Work?
First, it is important to understand the flow of JWT authentication:
- The user logs in by entering their email address and password.
- The server verifies the login credentials in the database.
- If everything is correct, the server generates a JWT token.
- This token is sent to the client.
- The client includes this token in the "Authorization" header with every request.
- 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.