Nodejs

JWT Authentication in Express.js Applications

A complete guide to JWT authentication in Express.js covering token creation, access/refresh token patterns, middleware protection, role-based access, cookie storage, token rotation, and common security pitfalls.

JWT Authentication in Express.js Applications

Overview

JSON Web Tokens are the standard way to handle stateless authentication in Express.js APIs. They let you verify a user's identity on every request without hitting a database, which makes them ideal for distributed systems, microservices, and any API where horizontal scaling matters. This article covers the full lifecycle -- from understanding what a JWT actually is, through implementing a production-ready auth system with access tokens, refresh tokens, role-based access, and the security decisions that most tutorials skip.

Prerequisites

  • Node.js 18+ installed
  • Working knowledge of Express.js routing and middleware
  • Basic understanding of HTTP headers and cookies
  • A MongoDB or PostgreSQL database for user storage (examples use MongoDB)
  • Familiarity with password hashing concepts (bcrypt)

How JWTs Work

A JWT is three Base64URL-encoded strings separated by dots:

header.payload.signature

That is it. No magic. Let me break down each part.

Header

The header declares the token type and the signing algorithm:

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

This gets Base64URL-encoded into something like eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

Payload

The payload carries the claims -- the actual data you want to transmit. Standard claims include:

{
  "sub": "user_6472fa9c",
  "iat": 1706900000,
  "exp": 1706903600,
  "role": "editor"
}
  • sub (subject) -- who the token is about, usually a user ID
  • iat (issued at) -- Unix timestamp when the token was created
  • exp (expiration) -- Unix timestamp when the token expires
  • Custom claims like role carry your application-specific data

This also gets Base64URL-encoded. Important: Base64 is encoding, not encryption. Anyone can decode the payload. Never put passwords, credit card numbers, or secrets in a JWT.

Signature

The signature prevents tampering. For HMAC-SHA256, it works like this:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The server signs the token with a secret key. When the token comes back on a subsequent request, the server recalculates the signature using the same secret. If someone modified the payload, the signatures will not match and the token is rejected.

HMAC vs RSA

You have two main signing strategies:

HMAC (HS256) -- Symmetric. One secret key signs and verifies. Simple, fast, perfect for monolithic applications where the same server issues and verifies tokens.

RSA (RS256) -- Asymmetric. A private key signs; a public key verifies. Use this when different services need to verify tokens but should not be able to issue them. Your auth service holds the private key, your microservices only need the public key.

For most Express.js applications, HS256 is the right choice. I use RS256 when I have a dedicated auth service issuing tokens for multiple downstream APIs.


When to Use JWTs vs Sessions

This is one of those debates that generates more heat than light. Here is my take after building both in production:

Use JWTs when:

  • You are building an API consumed by mobile apps or SPAs
  • You need stateless authentication across multiple servers
  • You have microservices that need to verify identity independently
  • You want to avoid server-side session storage

Use server-side sessions when:

  • You are building a traditional server-rendered application
  • You need instant session revocation (logout means logout, immediately)
  • Your app runs on a single server or you already have Redis in your stack
  • You do not want to deal with refresh token rotation

The honest truth is that JWTs push complexity to the client. Sessions push complexity to the server. Pick the tradeoff that fits your architecture.


Creating Tokens with jsonwebtoken

Install the library:

npm install jsonwebtoken

Signing Tokens

var jwt = require('jsonwebtoken');

var SECRET = process.env.JWT_SECRET;

var payload = {
    sub: 'user_6472fa9c',
    email: '[email protected]',
    role: 'admin'
};

var options = {
    expiresIn: '15m',
    issuer: 'grizzlypeaksoftware.com',
    audience: 'grizzlypeaksoftware.com'
};

var token = jwt.sign(payload, SECRET, options);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzY0...

The expiresIn option accepts human-readable strings: '15m', '1h', '7d'. Under the hood, it sets the exp claim automatically.

Verifying Tokens

try {
    var decoded = jwt.verify(token, SECRET, {
        issuer: 'grizzlypeaksoftware.com',
        audience: 'grizzlypeaksoftware.com'
    });
    console.log(decoded);
    // { sub: 'user_6472fa9c', email: '[email protected]', role: 'admin', iat: 1706900000, exp: 1706900900, iss: 'grizzlypeaksoftware.com', aud: 'grizzlypeaksoftware.com' }
} catch (err) {
    if (err.name === 'TokenExpiredError') {
        console.log('Token expired at:', err.expiredAt);
    } else if (err.name === 'JsonWebTokenError') {
        console.log('Invalid token:', err.message);
    }
}

Always verify with the same issuer and audience you signed with. This prevents tokens issued by a different service from being accepted.

Decoding Without Verification

var decoded = jwt.decode(token, { complete: true });
console.log(decoded.header);  // { alg: 'HS256', typ: 'JWT' }
console.log(decoded.payload); // { sub: 'user_6472fa9c', ... }

Use jwt.decode() only for debugging. Never use it to make authorization decisions -- it does not verify the signature.


Token Payload Design

Keep your JWT payloads lean. Every token travels with every request, and each byte adds up.

Good payload:

{
  "sub": "user_6472fa9c",
  "role": "editor",
  "permissions": ["articles:write", "articles:read"],
  "iat": 1706900000,
  "exp": 1706900900
}

Bad payload:

{
  "sub": "user_6472fa9c",
  "firstName": "Shane",
  "lastName": "Developer",
  "email": "[email protected]",
  "avatar": "https://cdn.example.com/avatars/shane.jpg",
  "preferences": { "theme": "dark", "language": "en" },
  "lastLogin": "2026-01-15T10:00:00Z",
  "createdAt": "2024-03-01T08:00:00Z"
}

The bad example bloats the token and includes data that changes frequently. If a user updates their email, every outstanding token has stale data. Put volatile data in a database and look it up when needed.

My rule: include only the data needed for authorization decisions -- user ID, role, and permissions. Everything else comes from a database query.


Access Tokens vs Refresh Tokens

This is the pattern that separates toy auth systems from production ones.

Access tokens are short-lived (5-15 minutes). They carry user claims and are sent with every API request. If one gets stolen, the damage window is small.

Refresh tokens are long-lived (7-30 days). They are used only to get new access tokens. They are stored more securely and can be revoked server-side.

Why do you need both? Because short-lived access tokens mean users would have to log in every 15 minutes without refresh tokens. And long-lived access tokens are a security nightmare -- if one is stolen, the attacker has extended access with no way to revoke it (since JWTs are stateless).

The flow looks like this:

1. User logs in with credentials
2. Server issues access token (15min) + refresh token (7 days)
3. Client sends access token with every API request
4. Access token expires
5. Client sends refresh token to /auth/refresh
6. Server validates refresh token, issues new access token
7. Client continues making requests with new access token

Implementing a Login Endpoint

var express = require('express');
var jwt = require('jsonwebtoken');
var bcrypt = require('bcrypt');
var crypto = require('crypto');
var router = express.Router();

var ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
var REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
var ACCESS_EXPIRY = '15m';
var REFRESH_EXPIRY = '7d';

function generateAccessToken(user) {
    var payload = {
        sub: user._id.toString(),
        email: user.email,
        role: user.role
    };
    return jwt.sign(payload, ACCESS_SECRET, {
        expiresIn: ACCESS_EXPIRY,
        issuer: 'grizzlypeaksoftware.com'
    });
}

function generateRefreshToken(user) {
    var payload = {
        sub: user._id.toString(),
        tokenVersion: user.tokenVersion || 0,
        jti: crypto.randomBytes(16).toString('hex')
    };
    return jwt.sign(payload, REFRESH_SECRET, {
        expiresIn: REFRESH_EXPIRY,
        issuer: 'grizzlypeaksoftware.com'
    });
}

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

    if (!email || !password) {
        return res.status(400).json({ error: 'Email and password are required' });
    }

    // Find user in database
    User.findOne({ email: email.toLowerCase() }, function(err, user) {
        if (err) {
            return res.status(500).json({ error: 'Internal server error' });
        }

        if (!user) {
            return res.status(401).json({ error: 'Invalid email or password' });
        }

        // Compare passwords
        bcrypt.compare(password, user.passwordHash, function(err, match) {
            if (err) {
                return res.status(500).json({ error: 'Internal server error' });
            }

            if (!match) {
                return res.status(401).json({ error: 'Invalid email or password' });
            }

            var accessToken = generateAccessToken(user);
            var refreshToken = generateRefreshToken(user);

            // Store refresh token hash in database
            var refreshHash = crypto
                .createHash('sha256')
                .update(refreshToken)
                .digest('hex');

            User.updateOne(
                { _id: user._id },
                { $set: { refreshTokenHash: refreshHash } },
                function(err) {
                    if (err) {
                        return res.status(500).json({ error: 'Internal server error' });
                    }

                    // Set refresh token as httpOnly cookie
                    res.cookie('refreshToken', refreshToken, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === 'production',
                        sameSite: 'strict',
                        maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
                        path: '/api/auth'
                    });

                    res.json({
                        accessToken: accessToken,
                        user: {
                            id: user._id,
                            email: user.email,
                            role: user.role
                        }
                    });
                }
            );
        });
    });
});

Notice a few critical details:

  1. The error message for both wrong email and wrong password is identical: "Invalid email or password". Never reveal whether the email exists.
  2. The refresh token is stored as a SHA-256 hash, not in plain text. If the database is compromised, the attacker cannot use the stored hashes.
  3. The refresh token cookie is scoped to /api/auth -- it is only sent with auth-related requests, not every API call.

Middleware for Protecting Routes

var jwt = require('jsonwebtoken');

var ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;

function authenticate(req, res, next) {
    var authHeader = req.headers.authorization;

    if (!authHeader) {
        return res.status(401).json({
            error: 'MISSING_TOKEN',
            message: 'Authorization header is required'
        });
    }

    var parts = authHeader.split(' ');

    if (parts.length !== 2 || parts[0] !== 'Bearer') {
        return res.status(401).json({
            error: 'INVALID_FORMAT',
            message: 'Authorization header must be: Bearer <token>'
        });
    }

    var token = parts[1];

    try {
        var decoded = jwt.verify(token, ACCESS_SECRET, {
            issuer: 'grizzlypeaksoftware.com'
        });

        req.user = {
            id: decoded.sub,
            email: decoded.email,
            role: decoded.role
        };

        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({
                error: 'TOKEN_EXPIRED',
                message: 'Access token has expired'
            });
        }

        return res.status(401).json({
            error: 'INVALID_TOKEN',
            message: 'Token verification failed'
        });
    }
}

module.exports = authenticate;

Use it on your routes:

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

// Protect all routes under /api/users
app.use('/api/users', authenticate);

// Or protect individual routes
router.get('/profile', authenticate, function(req, res) {
    res.json({ userId: req.user.id, role: req.user.role });
});

The middleware extracts the token from the Authorization: Bearer <token> header, verifies it, and attaches the decoded user data to req.user. Downstream handlers can then use req.user.id and req.user.role without touching JWT logic.


Refresh Token Rotation

When a client presents a refresh token to get a new access token, you should also issue a new refresh token. This is called rotation, and it limits the damage if a refresh token is stolen.

router.post('/refresh', function(req, res) {
    var refreshToken = req.cookies.refreshToken;

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

    try {
        var decoded = jwt.verify(refreshToken, REFRESH_SECRET, {
            issuer: 'grizzlypeaksoftware.com'
        });

        var userId = decoded.sub;
        var tokenVersion = decoded.tokenVersion;

        User.findById(userId, function(err, user) {
            if (err || !user) {
                return res.status(401).json({ error: 'User not found' });
            }

            // Check token version -- if user has logged out or rotated,
            // the stored version will be higher
            if (user.tokenVersion !== tokenVersion) {
                // Possible token reuse attack -- invalidate all tokens
                User.updateOne(
                    { _id: userId },
                    { $inc: { tokenVersion: 1 }, $unset: { refreshTokenHash: '' } },
                    function() {
                        return res.status(401).json({
                            error: 'TOKEN_REUSED',
                            message: 'Refresh token has been revoked. Please log in again.'
                        });
                    }
                );
                return;
            }

            // Verify the refresh token hash matches
            var incomingHash = crypto
                .createHash('sha256')
                .update(refreshToken)
                .digest('hex');

            if (incomingHash !== user.refreshTokenHash) {
                return res.status(401).json({ error: 'Invalid refresh token' });
            }

            // Issue new token pair
            var newAccessToken = generateAccessToken(user);
            var newRefreshToken = generateRefreshToken(user);

            var newRefreshHash = crypto
                .createHash('sha256')
                .update(newRefreshToken)
                .digest('hex');

            User.updateOne(
                { _id: userId },
                { $set: { refreshTokenHash: newRefreshHash } },
                function(err) {
                    if (err) {
                        return res.status(500).json({ error: 'Internal server error' });
                    }

                    res.cookie('refreshToken', newRefreshToken, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === 'production',
                        sameSite: 'strict',
                        maxAge: 7 * 24 * 60 * 60 * 1000,
                        path: '/api/auth'
                    });

                    res.json({ accessToken: newAccessToken });
                }
            );
        });
    } catch (err) {
        // Clear the invalid cookie
        res.clearCookie('refreshToken', { path: '/api/auth' });
        return res.status(401).json({ error: 'Invalid or expired refresh token' });
    }
});

The tokenVersion field is key. When a user logs out or you detect suspicious activity, you increment tokenVersion in the database. Any refresh token with the old version is immediately rejected. If an attacker tries to reuse a previously rotated refresh token, you detect the version mismatch and invalidate everything -- forcing the legitimate user to re-authenticate, but locking out the attacker.


Token Revocation Strategies

JWTs are stateless, which means you cannot truly "invalidate" them without introducing some state. Here are three strategies, ranked by my preference:

1. Short Expiry + Refresh Token Rotation (Recommended)

Keep access tokens short (5-15 minutes). When you need to revoke access, invalidate the refresh token. The access token dies on its own within minutes.

2. Token Versioning

Store a tokenVersion counter on the user record. Include it in the token payload. On every request, compare the token's version with the database version. This adds a database lookup but gives you instant revocation.

function authenticateWithVersionCheck(req, res, next) {
    // ... extract and verify token ...

    User.findById(decoded.sub, 'tokenVersion', function(err, user) {
        if (err || !user || user.tokenVersion !== decoded.tokenVersion) {
            return res.status(401).json({ error: 'Token has been revoked' });
        }
        req.user = decoded;
        next();
    });
}

3. Token Blacklist

Maintain a set of revoked token IDs (the jti claim) in Redis. Check every incoming token against the blacklist. This gives per-token revocation but requires Redis infrastructure.

var redis = require('redis');
var client = redis.createClient();

function checkBlacklist(req, res, next) {
    var jti = req.user.jti;

    client.get('blacklist:' + jti, function(err, result) {
        if (result) {
            return res.status(401).json({ error: 'Token has been revoked' });
        }
        next();
    });
}

// When revoking a token
function revokeToken(jti, expiresIn) {
    client.set('blacklist:' + jti, '1', 'EX', expiresIn);
}

I typically go with strategy 1 for most applications. It keeps the system stateless for the majority of requests and only requires a database hit during token refresh.


Role-Based Access Control with JWT Claims

Once you have user roles in your JWT payload, building RBAC middleware is straightforward:

function authorize() {
    var allowedRoles = Array.prototype.slice.call(arguments);

    return function(req, res, next) {
        if (!req.user) {
            return res.status(401).json({ error: 'Authentication required' });
        }

        if (allowedRoles.indexOf(req.user.role) === -1) {
            return res.status(403).json({
                error: 'FORBIDDEN',
                message: 'You do not have permission to access this resource'
            });
        }

        next();
    };
}

// Usage
router.get('/admin/users', authenticate, authorize('admin'), function(req, res) {
    // Only admins can list all users
});

router.put('/articles/:id', authenticate, authorize('admin', 'editor'), function(req, res) {
    // Admins and editors can update articles
});

router.get('/articles', authenticate, authorize('admin', 'editor', 'viewer'), function(req, res) {
    // Everyone can read articles
});

For more granular control, use permission-based claims instead of roles:

function requirePermission(permission) {
    return function(req, res, next) {
        if (!req.user || !req.user.permissions) {
            return res.status(403).json({ error: 'Insufficient permissions' });
        }

        if (req.user.permissions.indexOf(permission) === -1) {
            return res.status(403).json({
                error: 'FORBIDDEN',
                message: 'Required permission: ' + permission
            });
        }

        next();
    };
}

// Usage
router.delete('/articles/:id', authenticate, requirePermission('articles:delete'), handler);

Storing Tokens on the Client

This is where I am going to be opinionated, because the internet is full of bad advice on this topic.

Do not store tokens in localStorage. Full stop.

LocalStorage is accessible to any JavaScript running on your page. A single XSS vulnerability -- from a compromised npm package, an ad script, or a DOM injection -- gives an attacker your tokens. Game over.

Use httpOnly cookies for refresh tokens. They cannot be read by JavaScript. They are sent automatically with requests to the cookie's domain and path. Combined with secure and sameSite flags, they are significantly harder to steal.

Access tokens can live in memory. Store the access token in a JavaScript variable (not localStorage). When the page refreshes, the token is gone -- but that is fine. Use the refresh token cookie to silently get a new access token on page load.

// Client-side pattern
var accessToken = null;

function login(email, password) {
    return fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // sends cookies
        body: JSON.stringify({ email: email, password: password })
    })
    .then(function(res) { return res.json(); })
    .then(function(data) {
        accessToken = data.accessToken; // stored in memory only
        return data;
    });
}

function apiRequest(url, options) {
    options = options || {};
    options.headers = options.headers || {};
    options.headers['Authorization'] = 'Bearer ' + accessToken;
    options.credentials = 'include';

    return fetch(url, options).then(function(res) {
        if (res.status === 401) {
            // Access token expired, try refresh
            return refreshAccessToken().then(function() {
                options.headers['Authorization'] = 'Bearer ' + accessToken;
                return fetch(url, options);
            });
        }
        return res;
    });
}

function refreshAccessToken() {
    return fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include' // sends refreshToken cookie
    })
    .then(function(res) { return res.json(); })
    .then(function(data) {
        accessToken = data.accessToken;
    });
}

CSRF Protection When Using Cookies

When you store tokens in cookies, you open the door to Cross-Site Request Forgery (CSRF) attacks. A malicious site can trick the user's browser into sending requests to your API with the user's cookies attached.

Mitigation strategies:

1. SameSite=Strict cookie flag -- The browser will not send the cookie on cross-origin requests. This is your first line of defense and handles most scenarios.

2. CSRF token pattern -- Generate a random token, store it in the JWT or a separate cookie, and require the client to send it as a custom header:

var crypto = require('crypto');

// On login, send a CSRF token alongside the access token
var csrfToken = crypto.randomBytes(32).toString('hex');

res.cookie('csrfToken', csrfToken, {
    httpOnly: false, // client needs to read this
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
});

// Middleware to verify CSRF token
function verifyCsrf(req, res, next) {
    var cookieToken = req.cookies.csrfToken;
    var headerToken = req.headers['x-csrf-token'];

    if (!cookieToken || !headerToken || cookieToken !== headerToken) {
        return res.status(403).json({ error: 'CSRF validation failed' });
    }

    next();
}

The client reads the CSRF token from the non-httpOnly cookie and sends it as a header. An attacker's site can trigger the cookie to be sent, but cannot read its value due to the same-origin policy -- so they cannot set the matching header.

3. Custom request headers -- Browsers do not send custom headers on simple cross-origin requests. Requiring Authorization: Bearer <token> as a header (instead of a cookie) provides inherent CSRF protection for API requests.

My recommendation: use SameSite=Strict on all auth cookies plus require custom headers for state-changing operations.


Handling Token Expiration Gracefully

Never show users a raw 401 error. Build automatic retry logic:

// Express middleware to add token expiry info to responses
function addTokenExpiryHeader(req, res, next) {
    var originalJson = res.json.bind(res);

    res.json = function(body) {
        if (req.user && req.user.exp) {
            var expiresIn = req.user.exp - Math.floor(Date.now() / 1000);
            res.setHeader('X-Token-Expires-In', expiresIn);
        }
        return originalJson(body);
    };

    next();
}

On the client, use the header to proactively refresh before expiration:

function apiRequest(url, options) {
    options = options || {};
    options.headers = options.headers || {};
    options.headers['Authorization'] = 'Bearer ' + accessToken;
    options.credentials = 'include';

    return fetch(url, options).then(function(res) {
        // Proactive refresh: if token expires in less than 60 seconds
        var expiresIn = parseInt(res.headers.get('X-Token-Expires-In'), 10);
        if (expiresIn && expiresIn < 60) {
            refreshAccessToken(); // fire and forget
        }

        if (res.status === 401) {
            return refreshAccessToken().then(function() {
                options.headers['Authorization'] = 'Bearer ' + accessToken;
                return fetch(url, options);
            });
        }

        return res;
    });
}

Common JWT Vulnerabilities

1. Algorithm Confusion (Critical)

Some JWT libraries allow the alg field in the token header to control which algorithm is used for verification. An attacker can change alg from RS256 to HS256 and sign the token with the public key (which is often publicly available). The server, now using HMAC, treats the public key as the HMAC secret and accepts the forged token.

Fix: Always specify the algorithm explicitly during verification:

jwt.verify(token, SECRET, { algorithms: ['HS256'] });

Never let the token dictate which algorithm to use.

2. Weak Secrets

Using secrets like "secret", "password", or "jwt-key" is depressingly common. These can be brute-forced in seconds.

Fix: Generate a random secret of at least 256 bits:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

3. Token Leakage via URL Parameters

Never send JWTs as URL query parameters. URLs are logged in server access logs, browser history, proxy logs, and analytics tools.

# NEVER do this
GET /api/data?token=eyJhbGciOiJIUzI1NiIs...

Always use the Authorization header or httpOnly cookies.

4. Missing Expiration

Tokens without an exp claim live forever. If one is compromised, the attacker has permanent access.

Fix: Always set expiresIn when signing tokens. Verify that exp is present during validation.

5. Not Validating Issuer and Audience

Without iss and aud validation, tokens from one application can be used in another if they share the same secret.

// Always validate these claims
jwt.verify(token, SECRET, {
    issuer: 'grizzlypeaksoftware.com',
    audience: 'grizzlypeaksoftware.com',
    algorithms: ['HS256']
});

Complete Working Example

Here is a full Express.js auth system with registration, login, refresh, logout, and role-based access. This is production-ready architecture -- not a toy example.

Project Structure

auth-demo/
  app.js
  middleware/
    authenticate.js
    authorize.js
  routes/
    auth.js
    users.js
  models/
    user.js
  package.json

package.json

{
  "name": "jwt-auth-demo",
  "version": "1.0.0",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "bcrypt": "^5.1.1",
    "cookie-parser": "^1.4.6",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.2",
    "mongoose": "^8.1.0"
  }
}

models/user.js

var mongoose = require('mongoose');
var bcrypt = require('bcrypt');

var userSchema = new mongoose.Schema({
    email: {
        type: String,
        required: true,
        unique: true,
        lowercase: true,
        trim: true
    },
    passwordHash: {
        type: String,
        required: true
    },
    role: {
        type: String,
        enum: ['viewer', 'editor', 'admin'],
        default: 'viewer'
    },
    tokenVersion: {
        type: Number,
        default: 0
    },
    refreshTokenHash: {
        type: String,
        default: null
    },
    createdAt: {
        type: Date,
        default: Date.now
    }
});

userSchema.statics.hashPassword = function(password, callback) {
    bcrypt.hash(password, 12, callback);
};

userSchema.methods.comparePassword = function(password, callback) {
    bcrypt.compare(password, this.passwordHash, callback);
};

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

middleware/authenticate.js

var jwt = require('jsonwebtoken');

var ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;

function authenticate(req, res, next) {
    var authHeader = req.headers.authorization;

    if (!authHeader) {
        return res.status(401).json({
            error: 'MISSING_TOKEN',
            message: 'Authorization header is required'
        });
    }

    var parts = authHeader.split(' ');

    if (parts.length !== 2 || parts[0] !== 'Bearer') {
        return res.status(401).json({
            error: 'INVALID_FORMAT',
            message: 'Format: Bearer <token>'
        });
    }

    try {
        var decoded = jwt.verify(parts[1], ACCESS_SECRET, {
            issuer: 'grizzlypeaksoftware.com',
            algorithms: ['HS256']
        });

        req.user = {
            id: decoded.sub,
            email: decoded.email,
            role: decoded.role
        };

        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({
                error: 'TOKEN_EXPIRED',
                message: 'Access token has expired',
                expiredAt: err.expiredAt
            });
        }

        return res.status(401).json({
            error: 'INVALID_TOKEN',
            message: 'Token verification failed'
        });
    }
}

module.exports = authenticate;

middleware/authorize.js

function authorize() {
    var allowedRoles = Array.prototype.slice.call(arguments);

    return function(req, res, next) {
        if (!req.user) {
            return res.status(401).json({
                error: 'UNAUTHENTICATED',
                message: 'Authentication is required'
            });
        }

        if (allowedRoles.length > 0 && allowedRoles.indexOf(req.user.role) === -1) {
            return res.status(403).json({
                error: 'FORBIDDEN',
                message: 'Insufficient permissions. Required role: ' + allowedRoles.join(' or ')
            });
        }

        next();
    };
}

module.exports = authorize;

routes/auth.js

var express = require('express');
var jwt = require('jsonwebtoken');
var crypto = require('crypto');
var User = require('../models/user');

var router = express.Router();

var ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
var REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

function generateAccessToken(user) {
    return jwt.sign(
        {
            sub: user._id.toString(),
            email: user.email,
            role: user.role
        },
        ACCESS_SECRET,
        {
            expiresIn: '15m',
            issuer: 'grizzlypeaksoftware.com'
        }
    );
}

function generateRefreshToken(user) {
    return jwt.sign(
        {
            sub: user._id.toString(),
            tokenVersion: user.tokenVersion,
            jti: crypto.randomBytes(16).toString('hex')
        },
        REFRESH_SECRET,
        {
            expiresIn: '7d',
            issuer: 'grizzlypeaksoftware.com'
        }
    );
}

function hashToken(token) {
    return crypto.createHash('sha256').update(token).digest('hex');
}

function setRefreshCookie(res, token) {
    res.cookie('refreshToken', token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000,
        path: '/api/auth'
    });
}

// POST /api/auth/register
router.post('/register', function(req, res) {
    var email = req.body.email;
    var password = req.body.password;

    if (!email || !password) {
        return res.status(400).json({ error: 'Email and password are required' });
    }

    if (password.length < 8) {
        return res.status(400).json({ error: 'Password must be at least 8 characters' });
    }

    User.findOne({ email: email.toLowerCase() }, function(err, existing) {
        if (err) {
            return res.status(500).json({ error: 'Internal server error' });
        }

        if (existing) {
            return res.status(409).json({ error: 'An account with this email already exists' });
        }

        User.hashPassword(password, function(err, hash) {
            if (err) {
                return res.status(500).json({ error: 'Internal server error' });
            }

            var user = new User({
                email: email.toLowerCase(),
                passwordHash: hash,
                role: 'viewer'
            });

            user.save(function(err, savedUser) {
                if (err) {
                    return res.status(500).json({ error: 'Internal server error' });
                }

                var accessToken = generateAccessToken(savedUser);
                var refreshToken = generateRefreshToken(savedUser);

                User.updateOne(
                    { _id: savedUser._id },
                    { $set: { refreshTokenHash: hashToken(refreshToken) } },
                    function(err) {
                        if (err) {
                            return res.status(500).json({ error: 'Internal server error' });
                        }

                        setRefreshCookie(res, refreshToken);

                        res.status(201).json({
                            accessToken: accessToken,
                            user: {
                                id: savedUser._id,
                                email: savedUser.email,
                                role: savedUser.role
                            }
                        });
                    }
                );
            });
        });
    });
});

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

    if (!email || !password) {
        return res.status(400).json({ error: 'Email and password are required' });
    }

    User.findOne({ email: email.toLowerCase() }, function(err, user) {
        if (err) {
            return res.status(500).json({ error: 'Internal server error' });
        }

        if (!user) {
            return res.status(401).json({ error: 'Invalid email or password' });
        }

        user.comparePassword(password, function(err, match) {
            if (err) {
                return res.status(500).json({ error: 'Internal server error' });
            }

            if (!match) {
                return res.status(401).json({ error: 'Invalid email or password' });
            }

            var accessToken = generateAccessToken(user);
            var refreshToken = generateRefreshToken(user);

            User.updateOne(
                { _id: user._id },
                { $set: { refreshTokenHash: hashToken(refreshToken) } },
                function(err) {
                    if (err) {
                        return res.status(500).json({ error: 'Internal server error' });
                    }

                    setRefreshCookie(res, refreshToken);

                    res.json({
                        accessToken: accessToken,
                        user: {
                            id: user._id,
                            email: user.email,
                            role: user.role
                        }
                    });
                }
            );
        });
    });
});

// POST /api/auth/refresh
router.post('/refresh', function(req, res) {
    var refreshToken = req.cookies.refreshToken;

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

    var decoded;
    try {
        decoded = jwt.verify(refreshToken, REFRESH_SECRET, {
            issuer: 'grizzlypeaksoftware.com',
            algorithms: ['HS256']
        });
    } catch (err) {
        res.clearCookie('refreshToken', { path: '/api/auth' });
        return res.status(401).json({ error: 'Invalid or expired refresh token' });
    }

    User.findById(decoded.sub, function(err, user) {
        if (err || !user) {
            return res.status(401).json({ error: 'User not found' });
        }

        // Token version check
        if (user.tokenVersion !== decoded.tokenVersion) {
            res.clearCookie('refreshToken', { path: '/api/auth' });
            return res.status(401).json({
                error: 'TOKEN_REVOKED',
                message: 'Refresh token has been revoked. Please log in again.'
            });
        }

        // Hash comparison
        var incomingHash = hashToken(refreshToken);
        if (incomingHash !== user.refreshTokenHash) {
            // Possible reuse attack -- revoke all tokens
            User.updateOne(
                { _id: user._id },
                { $inc: { tokenVersion: 1 }, $unset: { refreshTokenHash: '' } },
                function() {
                    res.clearCookie('refreshToken', { path: '/api/auth' });
                    return res.status(401).json({
                        error: 'TOKEN_REUSE_DETECTED',
                        message: 'Security alert: token reuse detected. All sessions revoked.'
                    });
                }
            );
            return;
        }

        // Issue new token pair (rotation)
        var newAccessToken = generateAccessToken(user);
        var newRefreshToken = generateRefreshToken(user);

        User.updateOne(
            { _id: user._id },
            { $set: { refreshTokenHash: hashToken(newRefreshToken) } },
            function(err) {
                if (err) {
                    return res.status(500).json({ error: 'Internal server error' });
                }

                setRefreshCookie(res, newRefreshToken);
                res.json({ accessToken: newAccessToken });
            }
        );
    });
});

// POST /api/auth/logout
router.post('/logout', function(req, res) {
    var refreshToken = req.cookies.refreshToken;

    if (refreshToken) {
        try {
            var decoded = jwt.verify(refreshToken, REFRESH_SECRET, {
                issuer: 'grizzlypeaksoftware.com',
                algorithms: ['HS256']
            });

            // Increment token version to invalidate all refresh tokens
            User.updateOne(
                { _id: decoded.sub },
                {
                    $inc: { tokenVersion: 1 },
                    $unset: { refreshTokenHash: '' }
                },
                function() {}
            );
        } catch (err) {
            // Token already invalid, that is fine
        }
    }

    res.clearCookie('refreshToken', { path: '/api/auth' });
    res.json({ message: 'Logged out successfully' });
});

module.exports = router;

routes/users.js

var express = require('express');
var authenticate = require('../middleware/authenticate');
var authorize = require('../middleware/authorize');

var router = express.Router();

// All routes require authentication
router.use(authenticate);

// GET /api/users/me -- any authenticated user
router.get('/me', function(req, res) {
    res.json({
        id: req.user.id,
        email: req.user.email,
        role: req.user.role
    });
});

// GET /api/users -- admin only
router.get('/', authorize('admin'), function(req, res) {
    var User = require('../models/user');

    User.find({}, 'email role createdAt', function(err, users) {
        if (err) {
            return res.status(500).json({ error: 'Internal server error' });
        }
        res.json({ users: users });
    });
});

// PATCH /api/users/:id/role -- admin only
router.patch('/:id/role', authorize('admin'), function(req, res) {
    var User = require('../models/user');
    var newRole = req.body.role;
    var validRoles = ['viewer', 'editor', 'admin'];

    if (validRoles.indexOf(newRole) === -1) {
        return res.status(400).json({
            error: 'Invalid role. Must be one of: ' + validRoles.join(', ')
        });
    }

    User.findByIdAndUpdate(
        req.params.id,
        { role: newRole },
        { new: true, select: 'email role' },
        function(err, user) {
            if (err || !user) {
                return res.status(404).json({ error: 'User not found' });
            }
            res.json({ user: user });
        }
    );
});

module.exports = router;

app.js

var express = require('express');
var mongoose = require('mongoose');
var cookieParser = require('cookie-parser');

var authRoutes = require('./routes/auth');
var userRoutes = require('./routes/users');

var app = express();
var PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json({ limit: '10kb' }));
app.use(cookieParser());

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

// Health check
app.get('/health', function(req, res) {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Error handler
app.use(function(err, req, res, next) {
    console.error('Unhandled error:', err);
    res.status(500).json({ error: 'Internal server error' });
});

// Connect to MongoDB and start server
mongoose.connect(process.env.MONGODB_URI, function(err) {
    if (err) {
        console.error('MongoDB connection error:', err);
        process.exit(1);
    }

    console.log('Connected to MongoDB');

    app.listen(PORT, function() {
        console.log('Auth server running on port ' + PORT);
    });
});

Testing the API with curl

# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "securepassword123"}' \
  -c cookies.txt

# Response:
# {
#   "accessToken": "eyJhbGciOiJIUzI1NiIs...",
#   "user": {
#     "id": "65a1b2c3d4e5f6a7b8c9d0e1",
#     "email": "[email protected]",
#     "role": "viewer"
#   }
# }

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

# Access a protected route
curl http://localhost:3000/api/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -b cookies.txt

# Refresh the access token (uses cookie automatically)
curl -X POST http://localhost:3000/api/auth/refresh \
  -b cookies.txt -c cookies.txt

# Response:
# { "accessToken": "eyJhbGciOiJIUzI1NiIs..." }

# Logout
curl -X POST http://localhost:3000/api/auth/logout \
  -b cookies.txt -c cookies.txt

# Response:
# { "message": "Logged out successfully" }

Common Issues and Troubleshooting

1. "JsonWebTokenError: jwt malformed"

JsonWebTokenError: jwt malformed
    at module.exports (/app/node_modules/jsonwebtoken/verify.js:75:17)

This happens when the token string is corrupted or you are passing the entire Authorization header instead of just the token. Check that you are splitting on ' ' and taking parts[1]:

// Wrong
var token = req.headers.authorization; // "Bearer eyJ..."

// Right
var token = req.headers.authorization.split(' ')[1]; // "eyJ..."

2. "TokenExpiredError: jwt expired" Immediately After Signing

TokenExpiredError: jwt expired
    at /app/node_modules/jsonwebtoken/verify.js:152:21

Your server clock is out of sync. JWTs use Unix timestamps, so a clock skew of even a few seconds can cause tokens to appear expired. On cloud servers, ensure NTP is configured. You can also add clock tolerance:

jwt.verify(token, SECRET, { clockTolerance: 30 }); // 30 second tolerance

3. "Error: secretOrPrivateKey must have a value"

Error: secretOrPrivateKey must have a value
    at module.exports (/app/node_modules/jsonwebtoken/sign.js:107:20)

Your JWT_SECRET environment variable is not set. The jsonwebtoken library throws this when secret is undefined or empty. Validate your environment variables on startup:

if (!process.env.JWT_ACCESS_SECRET) {
    console.error('FATAL: JWT_ACCESS_SECRET environment variable is not set');
    process.exit(1);
}

4. Refresh Token Cookie Not Being Sent

You set the cookie but the /refresh endpoint says no token is present. Common causes:

  • Missing credentials: 'include' on the client. fetch() does not send cookies by default for cross-origin requests. Always include credentials: 'include'.
  • Cookie path mismatch. If you set path: '/api/auth' on the cookie, it will not be sent to /auth/refresh. The path must match.
  • secure: true on localhost. Secure cookies are only sent over HTTPS. Set secure conditionally based on NODE_ENV.
  • Missing cookie-parser middleware. Without it, req.cookies is undefined.
// Make sure cookie-parser is loaded before your routes
var cookieParser = require('cookie-parser');
app.use(cookieParser());

5. "MongoError: E11000 duplicate key error" During Registration

MongoError: E11000 duplicate key error collection: auth.users index: email_1

A user with that email already exists. Always check for existing users before attempting to create one, and return a clear 409 Conflict response. Do not expose database errors to the client.


Best Practices

  • Use separate secrets for access and refresh tokens. If your access token secret is compromised, the attacker still cannot forge refresh tokens. Store them in separate environment variables: JWT_ACCESS_SECRET and JWT_REFRESH_SECRET.

  • Generate secrets with at least 256 bits of entropy. Use crypto.randomBytes(64).toString('hex') to generate secrets. Never use dictionary words, short strings, or predictable values.

  • Always specify the algorithm explicitly. Pass { algorithms: ['HS256'] } to jwt.verify(). This prevents algorithm confusion attacks where an attacker changes the token header to "alg": "none" or swaps from RS256 to HS256.

  • Hash refresh tokens before storing them in the database. If your database is breached, plain-text refresh tokens give attackers access to every user account. SHA-256 hash them before storage.

  • Implement refresh token rotation. Issue a new refresh token every time one is used. Track token versions to detect reuse. If a previously rotated token is presented, revoke all tokens for that user -- it indicates the token was stolen.

  • Keep access token payloads minimal. Include only what is needed for authorization: user ID, role, and permissions. Do not store profile data, preferences, or anything that changes frequently. Large tokens increase bandwidth usage on every request.

  • Set httpOnly, secure, and sameSite flags on all auth cookies. httpOnly prevents JavaScript access (XSS protection). secure ensures HTTPS-only transmission. sameSite: 'strict' prevents CSRF. These three flags together cover the most common cookie-based attack vectors.

  • Validate environment variables at startup. Your application should refuse to start if JWT_ACCESS_SECRET or JWT_REFRESH_SECRET are missing. A server running with undefined secrets will throw cryptic errors at runtime.

  • Return consistent error codes. Use structured error responses with machine-readable error codes (TOKEN_EXPIRED, INVALID_TOKEN, FORBIDDEN) alongside human-readable messages. This lets clients handle specific errors programmatically instead of parsing message strings.

  • Log authentication failures without exposing details. Log failed login attempts, invalid tokens, and suspicious patterns server-side. Never return details about why a token is invalid to the client beyond "expired" or "invalid" -- do not leak information about your verification process.


References

Powered by Contentful