0

JWT Authentication: Beginner to Advance Concepts

May 7, 2025

11 min read
JWTAuthenticationNode.jsTech

Beginner: Building a Simple JWT Authentication System

Let's start with a basic JWT authentication system using Node.js, Express, and the jsonwebtoken library. This example includes user login, token generation, and protecting routes.

Prerequisites

  • Node.js installed.
  • Basic knowledge of JavaScript and Express.

Install dependencies:

npm init -y
npm install express jsonwebtoken bcrypt

Step 1: Set Up the Server

Create a file named server.js and set up a basic Express server.

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
 
app.use(express.json());
 
// Simulated user database (replace with a real database in production)
const users = [
  {
    id: 1,
    email: 'user@example.com',
    password: '$2b$10$...hashedPassword...' // Hashed password (use bcrypt to generate)
  }
];
 
// Secret key for signing JWTs (store in environment variables in production)
const JWT_SECRET = 'your-secret-key';
 
app.listen(3000, () => console.log('Server running on  http://localhost:3000'));

Step 2: User Registration

Allow users to register with an email and password. Passwords are hashed using bcrypt.

app.post('/register', async (req, res) => {
  const { email, password } = req.body;
  
  // Check if user already exists
  if (users.find(user => user.email === email)) {
    return res.status(400).json({ message: 'User already exists' });
  }
 
  // Hash password
  const passwordHash = await bcrypt.hash(password, 10);
  
  // Save user
  const user = { id: users.length + 1, email, password: passwordHash };
  users.push(user);
  
  res.status(201).json({ message: 'User registered successfully' });
});

Step 3: User Login and JWT Generation

Validate credentials and issue a JWT upon successful login.

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  // Find user
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }
 
  // Verify password
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }
 
  // Generate JWT
  const token = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: '1h' } // Token expires in 1 hour
  );
 
  res.json({ token });
});

Step 4: Protecting Routes

Create a middleware to verify JWTs and protect routes.

// Middleware to verify JWT
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: 'No token provided' });
  }
 
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = payload; // Attach user data to request
    next();
  } catch (err) {
    res.status(403).json({ message: 'Invalid or expired token' });
  }
}
 
// Protected route
app.get('/protected', authenticateToken, (req, res) => {
  res.json({ message: `Welcome, user ${req.user.email}!` });
});

Step 5: Testing the System

Start the server:

node server.js

Register a user:

curl -X POST http://localhost:3000/register -H "Content-Type: application/json" -d '{"email":"john@example.com","password":"password123"}'

Log in to get a JWT:

curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email":"john@example.com","password":"password123"}'

Response: {"token":"eyJhbG..."}

Access the protected route with the JWT:

curl -X GET http://localhost:3000/protected -H "Authorization: Bearer eyJhbG..."

Response: {"message":"Welcome, user john@example.com!"}

Key Points for Beginners

  • JWT Structure: The token contains user data (payload) but is signed to prevent tampering.
  • Security: Store JWT_SECRET in environment variables (e.g., using dotenv).
  • Client-Side: In a browser, store the JWT in local storage or an HTTP-only cookie and send it in the Authorization header for API requests.


Intermediate: Enhancing JWT Authentication

Now, let's add features like refresh tokens, role-based authorization, and better security practices to make the system more robust.

Step 1: Refresh Tokens

JWTs typically have short expiration times (e.g., 1 hour) for security. Refresh tokens allow users to obtain new JWTs without re-entering credentials.

Implementation

Store refresh tokens in a database (or in-memory for this example).

Issue a refresh token during login.

Create an endpoint to exchange refresh tokens for new JWTs.

// In-memory refresh token store (use a database in production)
const refreshTokens = [];
 
app.post('/login', async (req, res) => {
  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({ message: 'Invalid credentials' });
  }
 
  // Generate access token
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: '15m' } // Short-lived access token
  );
 
  // Generate refresh token
  const refreshToken = jwt.sign(
    { userId: user.id },
    JWT_SECRET,
    { expiresIn: '7d' } // Long-lived refresh token
  );
  refreshTokens.push(refreshToken);
 
  res.json({ accessToken, refreshToken });
});
 
// Refresh token endpoint
app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken || !refreshTokens.includes(refreshToken)) {
    return res.status(403).json({ message: 'Invalid refresh token' });
  }
 
  try {
    const payload = jwt.verify(refreshToken, JWT_SECRET);
    const accessToken = jwt.sign(
      { userId: payload.userId, email: users.find(u => u.id === payload.userId).email },
      JWT_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken });
  } catch (err) {
    res.status(403).json({ message: 'Invalid refresh token' });
  }
});

Testing Refresh Tokens

Log in to get accessToken and refreshToken.

After the access token expires (15 minutes), use the refresh token:

curl -X POST http://localhost:3000/refresh -H "Content-Type: application/json" -d '{"refreshToken":"eyJhbG..."}'

Response: {"accessToken":"eyJhbG..."}

Step 2: Role-Based Authorization

Add roles to the JWT payload and enforce access control based on user roles (e.g., admin vs. user).

Implementation

Modify the user model to include roles.

Add role checks in middleware.

// Updated user model
const users = [
  {
    id: 1,
    email: 'admin@example.com',
    password: '$2b$10$...hashedPassword...',
    role: 'admin'
  },
  {
    id: 2,
    email: 'user@example.com',
    password: '$2b$10$...hashedPassword...',
    role: 'user'
  }
];
 
// Update login to include role in JWT
app.post('/login', async (req, res) => {
  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({ message: 'Invalid credentials' });
  }
 
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id },
    JWT_SECRET,
    { expiresIn: '7d' }
  );
  refreshTokens.push(refreshToken);
 
  res.json({ accessToken, refreshToken });
});
 
// Middleware for role-based access
function restrictTo(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ message: 'Insufficient permissions' });
    }
    next();
  };
}
 
// Admin-only route
app.get('/admin', authenticateToken, restrictTo('admin'), (req, res) => {
  res.json({ message: 'Admin-only content' });
});

Testing Role-Based Access

  • Log in as admin@example.com and access /admin (should succeed).
  • Log in as user@example.com and access /admin (should return 403).

Step 3: Secure Token Storage

Server: Store JWT_SECRET in environment variables using dotenv:

npm install dotenv

Create a .env file:

JWT_SECRET=your-secure-secret-key

Update server.js:

require('dotenv').config();
const JWT_SECRET = process.env.JWT_SECRET;

Client: Store JWTs securely:

Use HTTP-only cookies instead of local storage to mitigate XSS attacks.

Modify the server to set the token in a cookie:

app.post('/login', async (req, res) => {
  // ... (same login logic)
  res.cookie('accessToken', accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000 // 15 minutes
  });
  res.json({ refreshToken });
});
 
// Update middleware to read from cookie
function authenticateToken(req, res, next) {
  const token = req.cookies.accessToken;
  if (!token) {
    return res.status(401).json({ message: 'No token provided' });
  }
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    res.status(403).json({ message: 'Invalid or expired token' });
  }
}


Advanced: Token Revocation and Scalability

For production systems, you need to handle token revocation (e.g., for logout) and ensure scalability. Let's explore these advanced techniques.

Step 1: Token Revocation (Blacklisting)

JWTs are stateless and remain valid until they expire, which complicates logout. To revoke tokens, maintain a blacklist of invalidated tokens.

Implementation with Redis

Install Redis and the redis package:

npm install redis

Blacklist tokens on logout and check the blacklist in the middleware.

const redis = require('redis');
const client = redis.createClient({ url: 'redis://localhost:6379' });
client.connect();
 
// Logout endpoint
app.post('/logout', authenticateToken, async (req, res) => {
  const token = req.cookies.accessToken;
  const payload = req.user;
  
  // Blacklist token until its original expiration
  const expiresIn = payload.exp - Math.floor(Date.now() / 1000);
  await client.setEx(`blacklist:${token}`, expiresIn, 'blacklisted');
  
  res.clearCookie('accessToken');
  res.json({ message: 'Logged out' });
});
 
// Update middleware to check blacklist
async function authenticateToken(req, res, next) {
  const token = req.cookies.accessToken;
  if (!token) {
    return res.status(401).json({ message: 'No token provided' });
  }
 
  // Check blacklist
  const isBlacklisted = await client.get(`blacklist:${token}`);
  if (isBlacklisted) {
    return res.status(403).json({ message: 'Token revoked' });
  }
 
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    res.status(403).json({ message: 'Invalid or expired token' });
  }
}

Step 2: Scalability with Refresh Token Rotation

To enhance security, implement refresh token rotation, where a new refresh token is issued with each refresh request, and old ones are invalidated.

Implementation

Store refresh tokens with a unique ID and track their usage.

const { v4: uuidv4 } = require('uuid'); // npm install uuid
 
// Update refresh token store to include IDs
const refreshTokenStore = new Map(); // Key: tokenId, Value: { token, userId }
 
// Update login
app.post('/login', async (req, res) => {
  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({ message: 'Invalid credentials' });
  }
 
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id, tokenId: uuidv4() },
    JWT_SECRET,
    { expiresIn: '7d' }
  );
  refreshTokenStore.set(refreshToken.payload.tokenId, { token: refreshToken, userId: user.id });
 
  res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'strict' });
  res.json({ refreshToken });
});
 
// Update refresh endpoint
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(403).json({ message: 'No refresh token provided' });
  }
 
  try {
    const payload = jwt.verify(refreshToken, JWT_SECRET);
    const storedToken = refreshTokenStore.get(payload.tokenId);
    if (!storedToken || storedToken.token !== refreshToken) {
      return res.status(403).json({ message: 'Invalid refresh token' });
    }
 
    // Generate new access and refresh tokens
    const newAccessToken = jwt.sign(
      { userId: payload.userId, email: users.find(u => u.id === payload.userId).email, role: users.find(u => u.id === payload.userId).role },
      JWT_SECRET,
      { expiresIn: '15m' }
    );
    const newRefreshToken = jwt.sign(
      { userId: payload.userId, tokenId: uuidv4() },
      JWT_SECRET,
      { expiresIn: '7d' }
    );
 
    // Rotate refresh token
    refreshTokenStore.delete(payload.tokenId);
    refreshTokenStore.set(newRefreshToken.payload.tokenId, { token: newRefreshToken, userId: payload.userId });
 
    res.cookie('accessToken', newAccessToken, { httpOnly: true, secure: true, sameSite: 'strict' });
    res.json({ refreshToken: newRefreshToken });
  } catch (err) {
    res.status(403).json({ message: 'Invalid refresh token' });
  }
});

Step 3: Additional Security Measures

Asymmetric Keys: Use RSA or ECDSA instead of HMAC for signing JWTs in production. Store private keys securely and use public keys for verification.

const { publicKey, privateKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });
const token = jwt.sign({ userId: 1 }, privateKey, { algorithm: 'RS256', expiresIn: '1h' });
const payload = jwt.verify(token, publicKey);

Validate Claims: Check iss (issuer), aud (audience), and other claims in the JWT.

Rate Limiting: Use a library like express-rate-limit to prevent brute-force attacks on login endpoints.

CORS: Configure CORS to restrict token usage to trusted origins.

Conclusion

JWT-based authentication is a versatile and scalable solution for securing modern web applications. We started with a simple system for beginners, added refresh tokens and role-based authorization for intermediate users, and explored advanced techniques like token revocation and refresh token rotation for production-grade systems.

Key Takeaways

  • Beginner: Use jsonwebtoken for simple login and protected routes. Store tokens securely.
  • Intermediate: Implement refresh tokens and role-based access for better user experience and security.
  • Advanced: Handle token revocation with blacklisting, use refresh token rotation, and adopt asymmetric keys for enterprise-grade security.

Next Steps

  • Replace the in-memory user database with a real database (e.g., MongoDB, PostgreSQL).
  • Deploy the app with a secure configuration (HTTPS, environment variables).
  • Explore libraries like passport-jwt for streamlined JWT handling.
  • Integrate with a frontend (e.g., React, Vue) to manage tokens in a real-world app.

Thanks for reading! If you have any questions or need further assistance, feel free to reach out. Happy coding!

Was this article helpful?

JWTAuthenticationNode.jsTech