How to Implement JWT Authentication in Node.js (Complete Guide)

Step-by-step walkthrough of adding JWT authentication to your Node.js and Express app. Includes working code examples you can copy and paste.

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.