AboutBlogContact
BackendJune 18, 2015 5 min read 23

How to Implement JWT Authentication in Node.js + Express (2015)

AunimedaAunimeda
📋 Table of Contents

How to Implement JWT Authentication in Node.js + Express (2015)

Short answer: Use jsonwebtoken to sign tokens with a secret, verify them in Express middleware, store userId in payload. Sign access tokens with short expiry (15 min), issue refresh tokens with longer expiry (30 days) stored server-side.

In 2015, mobile APIs needed stateless auth — no sessions, no cookies. JWT filled that gap. Here's the production implementation we shipped, including the refresh token pattern we got wrong the first time.


Install

npm install express jsonwebtoken bcryptjs
# Node.js 4.x LTS (released October 2015) — first LTS with ES6 support

Token Generation

// auth/tokens.js
var jwt = require('jsonwebtoken');

var ACCESS_SECRET  = process.env.JWT_SECRET;       // 32+ random bytes
var REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

// Access token: short-lived, stateless
function generateAccessToken(userId, role) {
  return jwt.sign(
    { userId: userId, role: role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
}

// Refresh token: longer-lived, stored in DB
function generateRefreshToken(userId) {
  return jwt.sign(
    { userId: userId, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: '30d' }
  );
}

module.exports = { generateAccessToken, generateRefreshToken };

Login Endpoint

// routes/auth.js
var express  = require('express');
var bcrypt   = require('bcryptjs');
var db       = require('../db');
var tokens   = require('../auth/tokens');
var router   = express.Router();

router.post('/login', function(req, res) {
  var email    = req.body.email;
  var password = req.body.password;

  db.query('SELECT * FROM users WHERE email = ?', [email], function(err, rows) {
    if (err || rows.length === 0) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    var user = rows[0];

    bcrypt.compare(password, user.password_hash, function(err, valid) {
      if (!valid) {
        return res.status(401).json({ error: 'Invalid credentials' });
      }

      var accessToken  = tokens.generateAccessToken(user.id, user.role);
      var refreshToken = tokens.generateRefreshToken(user.id);

      // Store refresh token in DB (not just in client)
      // Critical: allows invalidation on logout/password change
      db.query(
        'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
        [user.id, refreshToken],
        function() {
          res.json({
            accessToken:  accessToken,
            refreshToken: refreshToken,
            expiresIn:    900  // 15 minutes in seconds
          });
        }
      );
    });
  });
});

module.exports = router;

Auth Middleware

// middleware/authenticate.js
var jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  var header = req.headers['authorization'];

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

  // Standard: "Authorization: Bearer <token>"
  var parts = header.split(' ');
  if (parts.length !== 2 || parts[0] !== 'Bearer') {
    return res.status(401).json({ error: 'Token format invalid' });
  }

  var token = parts[1];

  jwt.verify(token, process.env.JWT_SECRET, function(err, decoded) {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
      }
      return res.status(401).json({ error: 'Token invalid' });
    }

    req.userId = decoded.userId;
    req.userRole = decoded.role;
    next();
  });
}

// Role-based access: wrap authenticate
function requireRole(role) {
  return [authenticate, function(req, res, next) {
    if (req.userRole !== role && req.userRole !== 'admin') {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  }];
}

module.exports = { authenticate, requireRole };

Refresh Token Endpoint

// routes/auth.js (continued)
router.post('/refresh', function(req, res) {
  var refreshToken = req.body.refreshToken;

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

  // Verify JWT signature first
  jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, function(err, decoded) {
    if (err) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }

    // Then verify it exists in DB (not revoked)
    db.query(
      'SELECT * FROM refresh_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW()',
      [refreshToken, decoded.userId],
      function(err, rows) {
        if (err || rows.length === 0) {
          return res.status(401).json({ error: 'Refresh token revoked' });
        }

        // Rotate: delete old token, issue new pair
        db.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);

        var newAccess  = tokens.generateAccessToken(decoded.userId, rows[0].role);
        var newRefresh = tokens.generateRefreshToken(decoded.userId);

        db.query(
          'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
          [decoded.userId, newRefresh]
        );

        res.json({ accessToken: newAccess, refreshToken: newRefresh });
      }
    );
  });
});

Logout (Token Revocation)

router.post('/logout', authenticate, function(req, res) {
  var refreshToken = req.body.refreshToken;

  // Delete specific refresh token
  db.query(
    'DELETE FROM refresh_tokens WHERE user_id = ? AND token = ?',
    [req.userId, refreshToken],
    function() {
      res.json({ message: 'Logged out' });
    }
  );
});

// Logout everywhere (e.g., after password change)
router.post('/logout-all', authenticate, function(req, res) {
  db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [req.userId], function() {
    res.json({ message: 'All sessions terminated' });
  });
});

refresh_tokens table

CREATE TABLE refresh_tokens (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  user_id    INT NOT NULL,
  token      TEXT NOT NULL,    -- Full JWT string
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT NOW(),
  INDEX idx_user (user_id)
  -- No unique index on token: TEXT column, use application-level check
) ENGINE=InnoDB;

The Mistake We Made First

We stored JWTs in localStorage. In 2015, this was common advice. The problem: XSS vulnerability reads localStorage from injected JavaScript.

Fix: Access token in memory (JS variable), refresh token in httpOnly cookie. The access token lives only for 15 minutes — an XSS attack has a narrow window. The refresh token in httpOnly cookie is inaccessible to JS entirely.

// Sending refresh token as httpOnly cookie instead of body
res.cookie('refreshToken', newRefresh, {
  httpOnly: true,
  secure:   true,    // HTTPS only
  sameSite: 'Strict',
  maxAge:   30 * 24 * 60 * 60 * 1000  // 30 days in ms
});
res.json({ accessToken: newAccess });

This pattern held up. In 2025, it's still the recommended approach for web apps.


Usage on Protected Routes

var auth = require('./middleware/authenticate');

// Public route
app.get('/api/products', getProducts);

// Requires any authenticated user
app.get('/api/orders', auth.authenticate, getOrders);

// Requires admin role
app.delete('/api/users/:id', auth.requireRole('admin'), deleteUser);

Read Also

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)aunimeda
Backend

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)

Socket.io 1.x (2016) made WebSockets practical: automatic fallback to polling, rooms for namespacing, and a simple emit/on API. We used it to build a real-time order tracking dashboard. Here's the server setup, client integration, authentication via JWT, and scaling with Redis adapter.

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)aunimeda
Backend

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)

MySQL LIKE queries break at scale. When our product catalog reached 200k items, search took 4+ seconds. Elasticsearch 2.x solved it: 50ms search across 200k documents with relevance scoring, typo tolerance, and faceted filters. Here's the indexing strategy, mapping, and PHP/Node.js integration.

How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)aunimeda
Backend

How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)

Redis caching reduced our PHP app's average response time from 800ms to 40ms on product listing pages. The pattern: cache database query results with TTL, invalidate on write. Here's the exact implementation with cache key strategy, stampede prevention, and cache warming.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles