Building a Real-World Project with JWT Authentication

7 min read

Building a Real-World Project with JWT Authentication

Hook: JWT authentication is one of the most practical ways to secure modern APIs and single-page applications, but implementing it correctly in a production-grade project requires more than simply issuing a token after login. In this article, we will build a realistic architecture around JWT authentication, including login, access tokens, refresh tokens, route protection, logout, and security hardening.

Key Takeaways
  • Understand where JWT authentication fits in real-world systems.
  • Build access and refresh token flows for production use.
  • Protect APIs with middleware and role-aware authorization.
  • Reduce common risks such as token theft and poor secret handling.
  • Improve deployment security with headers, reverse proxies, and operational controls.

When teams adopt JWT authentication, they usually want stateless API security, better frontend-backend separation, and scalable authorization patterns. However, real-world success depends on secure token lifecycles, careful storage decisions, and infrastructure awareness. If you are also hardening your HTTP layer, it helps to review web security headers alongside your authentication design.

Why JWT Authentication Is Used in Real Projects

JWT authentication works well for distributed systems because a signed token can carry identity and authorization claims without requiring a server-side session lookup for every request. This makes it especially useful for SPAs, mobile apps, microservices, and API-first platforms.

Still, JWTs are not automatically secure. A robust implementation needs:

  • Short-lived access tokens
  • Refresh token rotation
  • Secure secret or key management
  • Revocation strategy
  • Transport security with HTTPS
  • Well-defined authorization checks

Project Architecture for JWT Authentication

Let us design a realistic backend using Node.js, Express, and MongoDB. The flow looks like this:

  1. User signs in with email and password.
  2. Server validates credentials.
  3. Server issues a short-lived access token and a longer-lived refresh token.
  4. Client sends the access token to protected API endpoints.
  5. When the access token expires, the client uses the refresh token to get a new one.
  6. On logout, refresh tokens are revoked or invalidated.
Component Role
Access Token Short-lived token used for API authorization
Refresh Token Longer-lived token used to obtain new access tokens
Auth Middleware Validates token signature and claims
User Store Stores users, roles, and refresh token metadata
Token Service Creates, verifies, rotates, and revokes tokens

Setting Up the JWT Authentication Backend

Initialize the Project

mkdir jwt-auth-project
cd jwt-auth-project
npm init -y
npm install express jsonwebtoken bcryptjs mongoose cookie-parser dotenv
npm install --save-dev nodemon

Basic Server Setup

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cookieParser = require('cookie-parser');

const app = express();
app.use(express.json());
app.use(cookieParser());

mongoose.connect(process.env.MONGO_URI)
  .then(() => console.log('MongoDB connected'))
  .catch(err => console.error(err));

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

User Model

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  password: {
    type: String,
    required: true
  },
  role: {
    type: String,
    default: 'user'
  },
  refreshToken: {
    type: String,
    default: null
  }
}, { timestamps: true });

module.exports = mongoose.model('User', userSchema);

Creating Tokens in JWT Authentication

In production, keep access tokens short-lived and refresh tokens separate. You can sign both using different secrets and expiration windows.

const jwt = require('jsonwebtoken');

function generateAccessToken(user) {
  return jwt.sign(
    { id: user._id, email: user.email, role: user.role },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { id: user._id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
}

module.exports = {
  generateAccessToken,
  generateRefreshToken
};

Implementing Signup and Login with JWT Authentication

Signup Route

const express = require('express');
const bcrypt = require('bcryptjs');
const User = require('./models/User');
const { generateAccessToken, generateRefreshToken } = require('./utils/token');

const router = express.Router();

router.post('/signup', async (req, res) => {
  try {
    const { email, password } = req.body;

    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const user = await User.create({ email, password: hashedPassword });

    res.status(201).json({ message: 'User created successfully', userId: user._id });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

module.exports = router;

Login Route

router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await User.findOne({ 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);

    user.refreshToken = refreshToken;
    await user.save();

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict'
    });

    res.json({ accessToken });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

Protecting Routes with JWT Authentication Middleware

const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1];

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

  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired token' });
    }

    req.user = user;
    next();
  });
}

module.exports = authenticateToken;

Using the Middleware

const express = require('express');
const authenticateToken = require('./middleware/auth');

const router = express.Router();

router.get('/profile', authenticateToken, async (req, res) => {
  res.json({ message: 'Protected profile data', user: req.user });
});

module.exports = router;

Refresh Token Flow in JWT Authentication

A real application should not force users to log in every 15 minutes. The refresh token endpoint issues a new access token after validating the refresh token and matching it against server-side stored state.

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

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

  try {
    const decoded = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
    const user = await User.findById(decoded.id);

    if (!user || user.refreshToken !== token) {
      return res.status(403).json({ message: 'Invalid refresh token' });
    }

    const newAccessToken = generateAccessToken(user);
    res.json({ accessToken: newAccessToken });
  } catch (error) {
    res.status(403).json({ message: 'Invalid or expired refresh token' });
  }
});

Logout and Revocation Strategy for JWT Authentication

One common misconception is that JWT authentication cannot support logout. In practice, logout is implemented by deleting or rotating the refresh token and clearing the client cookie.

router.post('/logout', async (req, res) => {
  const token = req.cookies.refreshToken;

  if (token) {
    const user = await User.findOne({ refreshToken: token });
    if (user) {
      user.refreshToken = null;
      await user.save();
    }
  }

  res.clearCookie('refreshToken', {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict'
  });

  res.json({ message: 'Logged out successfully' });
});
Pro Tip: Store refresh tokens in HttpOnly cookies instead of browser-accessible storage whenever possible. This reduces exposure to client-side script access and makes your JWT authentication design more resilient against common frontend attack paths.

Role-Based Authorization with JWT Authentication

Authentication proves identity, but authorization decides what the user is allowed to do. Add role checks for administrative endpoints.

function authorizeRoles(...roles) {
  return (req, res, next) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return res.status(403).json({ message: 'Forbidden' });
    }
    next();
  };
}

module.exports = authorizeRoles;
router.delete('/admin/users/:id', authenticateToken, authorizeRoles('admin'), async (req, res) => {
  res.json({ message: 'User deleted by admin' });
});

Production Hardening for JWT Authentication

1. Use HTTPS Everywhere

Without HTTPS, tokens can be intercepted in transit. This is non-negotiable in production.

2. Keep Tokens Minimal

Do not place sensitive data such as passwords, internal secrets, or excessive profile information inside JWT payloads.

3. Rotate Refresh Tokens

Refresh token rotation helps limit replay risk. Each refresh call should invalidate the previous refresh token and issue a new one.

4. Manage Reverse Proxy and Edge Security

If your application runs behind a proxy, secure forwarding and rate limiting matter. Teams deploying APIs behind a gateway often benefit from learning more about advanced Nginx features for TLS termination, request filtering, and performance tuning.

5. Protect Secrets Properly

Store signing secrets in environment variables or secret managers, and rotate them periodically.

6. Add Monitoring and Audit Trails

Track login attempts, refresh token usage, failed verifications, and suspicious geographic or device patterns.

Common Mistakes in JWT Authentication

  • Using long-lived access tokens
  • Storing sensitive data in token payloads
  • Skipping refresh token validation against persisted state
  • Ignoring logout and revocation workflows
  • Using local storage for every token without considering risk
  • Forgetting role-based authorization checks

Testing JWT Authentication End-to-End

A strong testing flow should cover:

  • Signup with valid and duplicate users
  • Login with correct and incorrect credentials
  • Access to protected endpoints with valid and invalid tokens
  • Token expiration behavior
  • Refresh endpoint issuing new access tokens
  • Logout invalidating future refresh attempts

Example Protected Request

curl -H "Authorization: Bearer ACCESS_TOKEN" http://localhost:3000/profile

When JWT Authentication Is the Right Choice

JWT authentication is a strong fit when you need stateless authorization for APIs, mobile clients, distributed systems, or frontend-backend separation. It may be less ideal if your application depends heavily on immediate server-side session invalidation for every request and does not benefit from token-based design.

FAQ: JWT Authentication

1. Is JWT authentication better than session-based authentication?

JWT authentication is not always better, but it is often more suitable for APIs, SPAs, and distributed systems. Session-based authentication can still be simpler for traditional server-rendered applications.

2. Where should I store JWT tokens in a browser app?

Access tokens are often kept in memory, while refresh tokens are commonly stored in secure HttpOnly cookies. The right choice depends on your architecture and threat model.

3. Can JWT authentication support logout?

Yes. A practical logout strategy usually revokes or deletes the refresh token, clears cookies, and prevents future token refresh operations.

Conclusion

Building a real-world system with JWT authentication requires more than a token generation snippet. You need a full lifecycle: credential validation, short-lived access tokens, secure refresh tokens, middleware-based protection, authorization rules, logout support, and operational hardening. When these pieces are implemented thoughtfully, JWT authentication becomes a reliable foundation for secure modern applications.

Leave a Reply

Your email address will not be published. Required fields are marked *