Alright, let's build JWT authentication from scratch. No hand-waving, no skipping the important parts. Just a straightforward implementation you can actually use in a real app.
I'm going to assume you know the basics of Node.js and Express. If you can build a simple API with a few routes, you're good to go. We'll start from a basic Express app and add JWT authentication step by step.
What We're Building
By the end of this guide, you'll have:
- A registration endpoint that creates new users
- A login endpoint that returns a JWT
- Protected routes that require a valid JWT
- Middleware that checks and verifies tokens
- Proper error handling for auth failures
This is production-ready code. I'm not going to show you toy examples that don't work in the real world.
Step 1: Install the Required Packages
First, you need a few packages. Here's what to install:
npm install express jsonwebtoken bcryptjs dotenv
What each package does:
- express: The web framework (you probably have this already)
- jsonwebtoken: Creates and verifies JWTs
- bcryptjs: Hashes passwords securely
- dotenv: Loads environment variables from a .env file
For this tutorial, I'm keeping the database simple. In production, you'd use MongoDB, PostgreSQL, or whatever you prefer. Here, I'll just use an array to store users so we can focus on the JWT part.
Step 2: Set Up Your Environment Variables
Create a .env file in your project root. This is where your JWT secret key goes:
JWT_SECRET=your_super_secret_key_here
PORT=3000
Generate a proper secret key using OpenSSL or our generator. Don't use "secret" or anything predictable. And obviously, add .env to your .gitignore file.
Step 3: Basic Express Server Setup
Let's start with a basic server. Create an index.js file:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();
const app = express();
app.use(express.json());
// In-memory user storage (use a real database in production)
const users = [];
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Nothing fancy yet. Just a server that can parse JSON and listen on port 3000.
Step 4: Create the Registration Endpoint
Users need to sign up before they can log in. Let's build a registration endpoint that hashes passwords and stores user data:
app.post('/api/register', async (req, res) => {
try {
const { username, email, password } = req.body;
// Validate input
if (!username || !email || !password) {
return res.status(400).json({
error: 'Please provide username, email, and password'
});
}
// Check if user already exists
const existingUser = users.find(u => u.email === email);
if (existingUser) {
return res.status(400).json({
error: 'User already exists'
});
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create new user
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword
};
users.push(newUser);
res.status(201).json({
message: 'User created successfully',
userId: newUser.id
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
This endpoint does a few important things:
- Validates that all required fields are present
- Checks if the email is already registered
- Hashes the password with bcrypt (never store plain text passwords)
- Stores the user and returns a success message
Step 5: Create the Login Endpoint
Now for the important part—logging in and getting a JWT. This is where we actually create the token:
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
error: 'Please provide email and password'
});
}
// Find user
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
// Create JWT token
const token = jwt.sign(
{
userId: user.id,
email: user.email
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
username: user.username,
email: user.email
}
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
Let me break down what's happening here:
- We find the user by email
- We compare the provided password with the hashed password
- If everything checks out, we create a JWT with
jwt.sign() - The token includes the user's ID and email
- We set it to expire in 1 hour
- We send back the token and some user info
Notice we never send the password back, even the hashed version. Only send data the client actually needs.
Step 6: Create Authentication Middleware
Now we need middleware that checks if incoming requests have a valid JWT. This is what protects your routes:
const authenticateToken = (req, res, next) => {
// Get token from header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
// Check if token exists
if (!token) {
return res.status(401).json({
error: 'Access token required'
});
}
// Verify token
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({
error: 'Invalid or expired token'
});
}
// Add user info to request
req.user = user;
next();
});
};
This middleware:
- Looks for a token in the Authorization header
- Expects the format:
Bearer your_token_here - Verifies the token with your secret key
- If valid, adds the decoded user data to
req.user - If invalid, returns an error
Step 7: Create Protected Routes
Now we can use that middleware to protect any route. Here's an example of a protected profile endpoint:
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user is available because of our middleware
const user = users.find(u => u.id === req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
id: user.id,
username: user.username,
email: user.email
});
});
See how easy that is? Just add authenticateToken to any route you want to protect. If the user doesn't have a valid token, they can't access it.
Step 8: Testing Your Implementation
Let's test this with curl or Postman. Here's the flow:
Register a new user:
curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{
"username": "john",
"email": "john@example.com",
"password": "mypassword123"
}'
Login and get a token:
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "mypassword123"
}'
Copy the token from the response. Then use it to access protected routes:
Access protected route:
curl -X GET http://localhost:3000/api/profile \
-H "Authorization: Bearer your_token_here"
If you send a valid token, you'll get the user data back. If not, you'll get an error.
Adding Refresh Tokens (Optional But Recommended)
Access tokens that last only an hour mean users have to log in constantly. The solution? Refresh tokens.
Here's a simple refresh token implementation:
// Store refresh tokens (use a database in production)
const refreshTokens = [];
// Generate refresh token on login
app.post('/api/login', async (req, res) => {
// ... previous login code ...
// Create access token (short-lived)
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Create refresh token (long-lived)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token
refreshTokens.push(refreshToken);
res.json({
accessToken,
refreshToken
});
});
// Endpoint to get new access token
app.post('/api/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
if (!refreshTokens.includes(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
const newAccessToken = jwt.sign(
{ userId: user.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
Now users can use their refresh token to get a new access token without logging in again.
Implementing Logout
With JWT, logout is tricky because tokens stay valid until they expire. The simple solution is to maintain a token blacklist:
const tokenBlacklist = [];
app.post('/api/logout', authenticateToken, (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
// Add token to blacklist
tokenBlacklist.push(token);
res.json({ message: 'Logged out successfully' });
});
// Update middleware to check blacklist
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// Check if token is blacklisted
if (tokenBlacklist.includes(token)) {
return res.status(403).json({ error: 'Token has been revoked' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
In production, use Redis or a database for the blacklist instead of an array in memory.
💡 Pro Tip: Clean up your blacklist periodically. Remove tokens that have already expired since they're useless anyway. Run a cleanup job every hour or so.
Error Handling Best Practices
Good error handling makes debugging way easier. Here's a better way to structure your error responses:
// Custom error response function
const sendError = (res, statusCode, message, details = null) => {
const error = { error: message };
if (details) error.details = details;
return res.status(statusCode).json(error);
};
// Example usage
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return sendError(res, 400, 'Validation failed', {
email: !email ? 'Email is required' : null,
password: !password ? 'Password is required' : null
});
}
// ... rest of login logic ...
} catch (error) {
console.error('Login error:', error);
return sendError(res, 500, 'Internal server error');
}
});
Security Checklist
Before you deploy this, make sure you've got these covered:
- ✅ Use a strong, random JWT secret (at least 256 bits)
- ✅ Store secrets in environment variables, never in code
- ✅ Always hash passwords with bcrypt
- ✅ Set appropriate token expiration times (15-60 minutes)
- ✅ Use HTTPS in production (tokens over HTTP can be intercepted)
- ✅ Validate and sanitize all user input
- ✅ Use helmet.js for additional security headers
- ✅ Implement rate limiting on auth endpoints
Complete Working Example
Here's the full code in one file. Copy this and you've got a working JWT implementation:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();
const app = express();
app.use(express.json());
const users = [];
const tokenBlacklist = [];
// Middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
if (tokenBlacklist.includes(token)) {
return res.status(403).json({ error: 'Token revoked' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' });
req.user = user;
next();
});
};
// Register
app.post('/api/register', async (req, res) => {
try {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'All fields required' });
}
if (users.find(u => u.email === email)) {
return res.status(400).json({ error: 'User exists' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword
};
users.push(newUser);
res.status(201).json({ message: 'User created', userId: newUser.id });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Login
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token, user: { id: user.id, username: user.username } });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Protected route
app.get('/api/profile', authenticateToken, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
id: user.id,
username: user.username,
email: user.email
});
});
// Logout
app.post('/api/logout', authenticateToken, (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
tokenBlacklist.push(token);
res.json({ message: 'Logged out' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
Next Steps
This implementation is solid, but here's what you should add for production:
- Connect to a real database (MongoDB, PostgreSQL, etc.)
- Add email verification on registration
- Implement password reset functionality
- Add rate limiting to prevent brute force attacks
- Use Redis for token blacklists and refresh tokens
- Add logging and monitoring
- Write tests for all your endpoints
Wrapping Up
That's it. You now have a complete, working JWT authentication system in Node.js and Express. The code is production-ready as long as you replace the in-memory storage with a real database.
JWT authentication isn't as scary as it seems. It's just a few endpoints, some middleware, and proper token handling. Take this code, adapt it to your needs, and you're good to go.