How to Implement JWT Authentication in Your API

JSON Web Tokens (JWT) have become the industry standard for API authentication, powering millions of applications worldwide. This comprehensive guide will teach you how to implement secure, scalable JWT authentication from scratch, with practical examples and security best practices.

What is JWT and Why Use It?

A JSON Web Token is a compact, URL-safe token format for securely transmitting information between parties. Unlike session-based authentication, JWTs are stateless—the server doesn’t need to store session data, making them ideal for distributed systems and microservices.

JWT Structure

A JWT consists of three Base64-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsImV4cCI6MTY4MzgwMDAwMH0.signature_hash

Breaking it down:

  1. Header: Algorithm and token type
  2. Payload: Claims (user data, permissions, expiration)
  3. Signature: Cryptographic signature ensuring integrity

Decoded example:

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "userId": "12345",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1683700000,
  "exp": 1683800000
}

Important: JWTs are signed, not encrypted. Never store sensitive data like passwords in the payload[1].

Implementation: Node.js + Express

Let’s build a complete authentication system with registration, login, and protected routes.

Step 1: Setup and Dependencies

npm init -y
npm install express jsonwebtoken bcrypt dotenv express-validator
npm install --save-dev nodemon

Create your environment variables:

# .env
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
PORT=3000

Step 2: Create Authentication Middleware

// middleware/auth.js
const jwt = require('jsonwebtoken');

const authenticateToken = (req, res, next) => {
  // Get token from header
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({ 
      error: 'Access denied. No token provided.' 
    });
  }

  try {
    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ 
        error: 'Token expired.' 
      });
    }
    return res.status(403).json({ 
      error: 'Invalid token.' 
    });
  }
};

module.exports = { authenticateToken };

Step 3: User Registration

// routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');

const router = express.Router();

// In-memory user store (use a database in production)
const users = [];

// Registration endpoint
router.post('/register', [
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 8 })
    .withMessage('Password must be at least 8 characters'),
  body('name').trim().notEmpty()
], async (req, res) => {
  // Validate input
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }

  const { email, password, name } = req.body;

  // Check if user exists
  if (users.find(u => u.email === email)) {
    return res.status(400).json({ 
      error: 'User already exists' 
    });
  }

  try {
    // Hash password
    const saltRounds = 12;
    const hashedPassword = await bcrypt.hash(password, saltRounds);

    // Create user
    const user = {
      id: users.length + 1,
      email,
      password: hashedPassword,
      name,
      role: 'user',
      createdAt: new Date()
    };

    users.push(user);

    // Generate token
    const token = jwt.sign(
      { 
        userId: user.id, 
        email: user.email,
        role: user.role 
      },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );

    res.status(201).json({
      message: 'User created successfully',
      token,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      }
    });
  } catch (error) {
    res.status(500).json({ 
      error: 'Error creating user' 
    });
  }
});

module.exports = router;

Step 4: User Login

// routes/auth.js (continued)

router.post('/login', [
  body('email').isEmail().normalizeEmail(),
  body('password').notEmpty()
], async (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }

  const { email, password } = req.body;

  // Find user
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ 
      error: 'Invalid credentials' 
    });
  }

  try {
    // Verify password
    const isValidPassword = await bcrypt.compare(
      password, 
      user.password
    );

    if (!isValidPassword) {
      return res.status(401).json({ 
        error: 'Invalid credentials' 
      });
    }

    // Generate access token
    const accessToken = jwt.sign(
      { 
        userId: user.id, 
        email: user.email,
        role: user.role 
      },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );

    // Generate refresh token (for token refresh flow)
    const refreshToken = jwt.sign(
      { userId: user.id },
      process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
    );

    res.json({
      message: 'Login successful',
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      }
    });
  } catch (error) {
    res.status(500).json({ 
      error: 'Error during login' 
    });
  }
});

Step 5: Protected Routes

// routes/users.js
const express = require('express');
const { authenticateToken } = require('../middleware/auth');

const router = express.Router();

// Protected route - requires valid JWT
router.get('/profile', authenticateToken, (req, res) => {
  // req.user is populated by authenticateToken middleware
  res.json({
    message: 'Access granted to protected route',
    user: req.user
  });
});

// Admin-only route
const requireAdmin = (req, res, next) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ 
      error: 'Admin access required' 
    });
  }
  next();
};

router.get('/admin', authenticateToken, requireAdmin, (req, res) => {
  res.json({ 
    message: 'Admin access granted',
    data: 'Sensitive admin data here'
  });
});

module.exports = router;

Step 6: Complete Server Setup

// server.js
require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');

const app = express();

// Middleware
app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

// Error handling
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

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

Testing Your Authentication

Register a New User

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "securepassword123",
    "name": "John Doe"
  }'

Login

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "securepassword123"
  }'

Access Protected Route

curl http://localhost:3000/api/users/profile \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

Implementing Token Refresh

Refresh tokens allow users to get new access tokens without re-authenticating:

// routes/auth.js (add this route)

router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(401).json({ 
      error: 'Refresh token required' 
    });
  }

  try {
    // Verify refresh token
    const decoded = jwt.verify(
      refreshToken, 
      process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET
    );

    // Generate new access token
    const accessToken = jwt.sign(
      { 
        userId: decoded.userId,
        email: decoded.email,
        role: decoded.role
      },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );

    res.json({ accessToken });
  } catch (error) {
    res.status(403).json({ 
      error: 'Invalid refresh token' 
    });
  }
});

Security Best Practices

1. Use Strong Secrets

// Generate a secure secret
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret); // Use this in production

2. Set Appropriate Expiration Times

Token TypeRecommended DurationUse Case
Access Token15-60 minutesAPI calls
Refresh Token7-30 daysToken renewal
Remember Me30-90 daysOptional UX

3. Implement Token Blacklisting

For logout functionality, maintain a blacklist of invalidated tokens:

// Simple in-memory blacklist (use Redis in production)
const tokenBlacklist = new Set();

router.post('/logout', authenticateToken, (req, res) => {
  const token = req.headers['authorization'].split(' ')[1];
  
  // Add token to blacklist
  tokenBlacklist.add(token);
  
  res.json({ message: 'Logged out successfully' });
});

// Update authenticateToken middleware
const authenticateToken = (req, res, next) => {
  const token = req.headers['authorization']?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  // Check blacklist
  if (tokenBlacklist.has(token)) {
    return res.status(401).json({ error: 'Token has been revoked' });
  }
  
  // ... rest of verification
};

4. HTTPS Only

Always use HTTPS in production to prevent token interception:

// Force HTTPS
app.use((req, res, next) => {
  if (process.env.NODE_ENV === 'production' && !req.secure) {
    return res.redirect('https://' + req.headers.host + req.url);
  }
  next();
});

5. Rate Limiting

Prevent brute-force attacks:

const rateLimit = require('express-rate-limit');

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: 'Too many login attempts, please try again later.'
});

router.post('/login', authLimiter, async (req, res) => {
  // ... login logic
});

JWT vs Session Comparison

FeatureJWTSessions
StorageClient-sideServer-side
ScalabilityExcellent (stateless)Requires session store
RevocationComplex (needs blacklist)Easy (delete session)
SizeLarger (sent with each request)Smaller (just session ID)
SecurityVulnerable if secret leakedVulnerable if session hijacked
Best ForMicroservices, APIs, SPAsTraditional web apps

Advanced: JWT with TypeScript

// types/auth.ts
export interface JWTPayload {
  userId: number;
  email: string;
  role: 'admin' | 'user';
  iat?: number;
  exp?: number;
}

// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { JWTPayload } from '../types/auth';

declare global {
  namespace Express {
    interface Request {
      user?: JWTPayload;
    }
  }
}

export const authenticateToken = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const authHeader = req.headers['authorization'];
  const token = authHeader?.split(' ')[1];

  if (!token) {
    res.status(401).json({ error: 'No token provided' });
    return;
  }

  try {
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET!
    ) as JWTPayload;
    
    req.user = decoded;
    next();
  } catch (error) {
    res.status(403).json({ error: 'Invalid token' });
  }
};

Common Pitfalls and Solutions

Issue: XSS Vulnerabilities

Solution: Never store JWTs in localStorage. Use httpOnly cookies or memory storage.

Issue: Token Expiration Handling

Solution: Implement automatic token refresh before expiration.

Issue: No Way to Revoke Tokens

Solution: Implement token blacklisting or use short-lived tokens with refresh tokens.

Conclusion

JWT authentication provides a scalable, stateless solution perfect for modern APIs and microservices. By following the security best practices outlined here—using strong secrets, implementing proper expiration, and protecting against common vulnerabilities—you can build a robust authentication system.

Start with the basic implementation, then enhance it with refresh tokens, rate limiting, and proper error handling as your application grows. Remember: security is not a one-time implementation but an ongoing process of monitoring, updating, and improving.

The code examples provided give you a production-ready foundation. Adapt them to your specific needs, always prioritizing security over convenience.

References

[1] Auth0. (2024). JSON Web Token Best Current Practices. Available at: https://auth0.com/docs/secure/tokens/json-web-tokens (Accessed: November 2025)

[2] OWASP Foundation. (2024). Authentication Cheat Sheet. Available at: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html (Accessed: November 2025)

[3] RFC 7519. (2015). JSON Web Token (JWT). Available at: https://datatracker.ietf.org/doc/html/rfc7519 (Accessed: November 2025)

Thank you for reading! If you have any feedback or comments, please send them to [email protected].