Skip to content
Squarebrackets Private Limited
Squarebrackets
Courses
Browse
Explore
Services
Learning
Prep
Code&Run
Whiteboard
LMS

Web Development

JWT Token from Scratch in Node.js: Step-by-Step Guide

Learn how to implement JWT authentication and refresh token rotation from scratch in Node.js using crypto and express, without relying on external libraries for the core logic.

Ghanshyam Dwivedi
Ghanshyam Dwivedi
· 16 min read · Updated
JWT Token from Scratch in Node.js: Step-by-Step GuideHover to zoom

When your API receives authentication requests, managing session state across clustered servers can introduce significant overhead. Learning how to build a JWT Token from Scratch in Node.js: Step-by-Step Guide lets you replace stateful server sessions with cryptographically signed tokens using native runtime tools without relying on heavy external dependencies.

Most developers install a heavy authentication library immediately, import a single helper function, and never look under the hood (often overlooking module bundling nuances detailed in our guide on Import vs Require in JavaScript: Key Differences Explained). When that abstraction throws an obscure cryptographic error in production, debugging becomes a guessing game. Building the mechanism yourself changes everything.

By the end of this guide, you will understand how JSON Web Tokens work structurally, how to encode and sign them using Node.js without third-party packages, how to implement a secure refresh token rotation flow, and how to protect your Express routes cleanly.

What is a JSON Web Token structurally?

A JSON Web Token is simply a string split into three distinct segments by two period characters. Each segment serves a precise cryptographic purpose and uses URL-safe base64 encoding rather than standard base64. If you have already reviewed what is a server and how web servers operate or our guide on creating a server with Express and HTTP methods, wiring token verification into your route handlers is the next logical step.

Cryptographic pipeline for assembling a signed JSON Web Token Flowchart showing header and payload encoded to base64url, hashed with HMAC SHA256 and secret key to create the signature, and concatenated into a token.

Think of a JWT like an event ticket with a holographic security seal. The printed front shows your seat number and ticket tier in plain text so any usher can read it instantly. The holographic seal on the back, however, was stamped by the box office using a private metal die; if anyone tries to scratch out their seat number and write a better one, the holographic stamp shatters and the usher rejects it at the gate.

The header defines the metadata, typically specifying the token type as JWT and the signing algorithm such as HS256. The payload contains your application claims, such as the user identifier, permissions, and an expiration timestamp. The signature binds the header and payload together using a secret key known only to your authorization server.

How do you set up the project and native crypto?

We will use Node.js version 22 and its built-in crypto module, meaning zero external dependencies for our token generation logic. Initialize a fresh Node.js project and install Express along with dotenv for environment management.

bash
mkdir custom-jwt-demo
cd custom-jwt-demo
npm init -y
npm install express dotenv cookie-parser
npm install --save-dev nodemon

Open your package.json file and add a development script using nodemon to keep your local server running smoothly during development. Create an entry point file named server.js and a .env file containing strong cryptographic secret keys for both access tokens and refresh tokens.

env
PORT=3000
JWT_SECRET=super_secret_access_key_987654321
JWT_REFRESH_SECRET=super_secret_refresh_key_123456789

How to implement base64url encoding in Node.js

Standard base64 encoding uses characters like plus signs and forward slashes, which break URL query parameters and authorization headers. JWT specifications mandate base64url encoding, which replaces those characters with hyphens and underscores and strips padding equal signs.

javascript
function base64UrlEncode(str) {
  return Buffer.from(str)
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}

function base64UrlDecode(base64UrlStr) {
  let base64 = base64UrlStr
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  
  while (base64.length % 4) {
    base64 += '=';
  }
  
  return Buffer.from(base64, 'base64').toString('utf8');
}

module.exports = { base64UrlEncode, base64UrlDecode };

Notice how the decoding function pads the incoming string back to a multiple of four before passing it to the buffer parser. Skipping this padding check results in intermittent decoding crashes when processing tokens with shorter payloads.

How do you write the token generation function?

Generating a token requires creating the header, stringifying the payload, encoding both segments, and generating an HMAC SHA256 signature using the secret. This is where most developers rely blindly on libraries, but writing it out exposes the exact mechanics.

javascript
const crypto = require('crypto');
const { base64UrlEncode } = require('./encoding');

function generateToken(payload, secret, expiresInSeconds) {
  const header = {
    alg: 'HS256',
    typ: 'JWT'
  };

  const encodedHeader = base64UrlEncode(JSON.stringify(header));
  const encodedPayload = base64UrlEncode(JSON.stringify({
    ...payload,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + expiresInSeconds
  }));

  const signatureInput = `${encodedHeader}.${encodedPayload}`;
  
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signatureInput)
    .digest('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');

  return `${signatureInput}.${signature}`;
}

module.exports = { generateToken };

How do you verify and decode a token?

Verification is the reverse process of generation. You split the incoming token string, recompute the signature using the header and payload combined with your secret, and compare your computed signature against the signature provided in the token.

javascript
const crypto = require('crypto');
const { base64UrlEncode, base64UrlDecode } = require('./encoding');

function verifyToken(token, secret) {
  const parts = token.split('.');
  if (parts.length !== 3) {
    throw new Error('Invalid token structure');
  }

  const [encodedHeader, encodedPayload, providedSignature] = parts;
  
  const header = JSON.parse(base64UrlDecode(encodedHeader));
  if (header.alg !== 'HS256' || header.typ !== 'JWT') {
    throw new Error('Unsupported or malformed token header');
  }

  const signatureInput = `${encodedHeader}.${encodedPayload}`;
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(signatureInput)
    .digest('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');

  const computedBuf = Buffer.from(computedSignature);
  const providedBuf = Buffer.from(providedSignature);
  if (computedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(computedBuf, providedBuf)) {
    throw new Error('Invalid token signature');
  }

  const payload = JSON.parse(base64UrlDecode(encodedPayload));
  
  if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) {
    throw new Error('Token has expired');
  }

  return payload;
}

module.exports = { verifyToken };

Never fall back to standard strict equality (===) when checking signatures. String comparisons short-circuit on the first mismatching byte, exposing an execution-time delta that lets an attacker deduce valid signatures byte by byte. Buffer.from combined with crypto.timingSafeEqual runs in constant time regardless of where discrepancies occur.

How does the Node.js refresh token rotation mechanism work?

Access tokens should have a short lifespan (such as 15 minutes) to limit exposure if intercepted. When an access token expires, forcing the user to log in again ruins the user experience. This is where refresh tokens come in: they live longer (e.g., 7 days) and are used exclusively to request a fresh access token.

In a robust architecture, refresh tokens are stored in an HTTP-only cookie so JavaScript cannot access them, protecting against cross-site scripting attacks. Additionally, production systems maintain an active refresh token store in Redis or PostgreSQL to enable token revocation on logout.

javascript
const express = require('express');
const cookieParser = require('cookie-parser');
const { generateToken } = require('./jwt');
const { verifyToken } = require('./jwt');

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

// In-memory token blacklist or active store for demonstration
const refreshTokensStore = new Set();

app.post('/api/login', (req, res) => {
  const { username, role } = req.body;
  if (username !== 'admin' || role !== 'developer') {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const userPayload = { username, role };
  const accessToken = generateToken(userPayload, process.env.JWT_SECRET, 900); // 15 mins
  const refreshToken = generateToken(userPayload, process.env.JWT_REFRESH_SECRET, 604800); // 7 days

  refreshTokensStore.add(refreshToken);

  // Store refresh token in secure HTTP-only cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken });
});

app.post('/api/refresh', (req, res) => {
  const cookies = req.cookies;
  if (!cookies?.refreshToken) {
    return res.status(401).json({ error: 'Refresh token missing' });
  }

  const refreshToken = cookies.refreshToken;
  if (!refreshTokensStore.has(refreshToken)) {
    return res.status(403).json({ error: 'Refresh token revoked or invalid' });
  }

  try {
    const decoded = verifyToken(refreshToken, process.env.JWT_REFRESH_SECRET);
    
    // Rotate refresh token: revoke old one, issue new pair
    refreshTokensStore.delete(refreshToken);
    
    const userPayload = { username: decoded.username, role: decoded.role };
    const newAccessToken = generateToken(userPayload, process.env.JWT_SECRET, 900);
    const newRefreshToken = generateToken(userPayload, process.env.JWT_REFRESH_SECRET, 604800);
    
    refreshTokensStore.add(newRefreshToken);

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

    res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Refresh token expired or tampered' });
  }
});

Refresh token rotation detects token theft attempts. If a compromised refresh token is used after its legitimate owner has already rotated it, your backend can invalidate all active tokens for that user account immediately.

How do you build Express middleware for authentication?

With generation and verification ready, we need an Express middleware to intercept incoming requests, extract the token from the Authorization header, verify it, and attach the user payload to the request object for downstream controllers.

javascript
const { verifyToken } = require('./jwt');

function requireAuth(req, res, next) {
  const authHeader = req.headers['authorization'];
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token missing or malformed' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decodedUser = verifyToken(token, process.env.JWT_SECRET);
    req.user = decodedUser;
    next();
  } catch (err) {
    return res.status(403).json({ error: err.message });
  }
}

module.exports = { requireAuth };

Returning status 401 when the header is missing and status 403 when the token is invalid or expired provides clear, predictable error contracts for frontend client applications.

Putting it together in a complete Express server

Let us wire everything together into server.js to test our complete custom authentication cycle. We will create a login route that issues tokens, a refresh route, and a protected dashboard route.

javascript
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const { generateToken, verifyToken } = require('./jwt');
const { requireAuth } = require('./middleware');

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

const refreshTokensStore = new Set();

app.post('/api/login', (req, res) => {
  const { username, role } = req.body;
  if (username !== 'admin' || role !== 'developer') {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const payload = { username, role };
  const accessToken = generateToken(payload, process.env.JWT_SECRET, 900);
  const refreshToken = generateToken(payload, process.env.JWT_REFRESH_SECRET, 604800);

  refreshTokensStore.add(refreshToken);

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: false, // set to true in production with HTTPS
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken });
});

app.post('/api/refresh', (req, res) => {
  const refreshToken = req.cookies?.refreshToken;
  if (!refreshToken || !refreshTokensStore.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }

  try {
    const decoded = verifyToken(refreshToken, process.env.JWT_REFRESH_SECRET);
    refreshTokensStore.delete(refreshToken);

    const payload = { username: decoded.username, role: decoded.role };
    const newAccessToken = generateToken(payload, process.env.JWT_SECRET, 900);
    const newRefreshToken = generateToken(payload, process.env.JWT_REFRESH_SECRET, 604800);

    refreshTokensStore.add(newRefreshToken);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: false,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Refresh token expired' });
  }
});

app.get('/api/dashboard', requireAuth, (req, res) => {
  res.json({
    message: 'Welcome to the secure dashboard',
    user: req.user
  });
});

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

Test this setup using curl or Postman. Send a POST request to /api/login with JSON body containing username: admin and role: developer to receive your signed access token and HTTP-only refresh cookie.

What changes in production environments?

Building a token utility from scratch teaches you the fundamentals, but production systems demand rigorous hardening. If you are designing comprehensive cloud infrastructures, learning enterprise practices through our structured Advanced DevOps and Infrastructure Engineering Course or our comprehensive Web Development Course domain will give you the pipeline patterns required for secure deployments.

Local DevelopmentProduction Scale
Secret ManagementHardcoded or .env fileVault service / Secrets Manager
Token TransportJSON response bodyHTTP-only Secure Cookies
Token LifecycleLong expirationShort-lived tokens with Refresh rotation
AlgorithmHS256 (Symmetric)RS256 (Asymmetric key pairs)

Moving to asymmetric signing (RS256) is critical when you have microservices that need to verify tokens issued by an authentication service without sharing the private signing key across every single container in your cluster.

Common mistakes learners make with JWT implementation

The most frequent error is storing sensitive Personally Identifiable Information like passwords, social security numbers, or billing data directly inside the JWT payload. Remember that anyone with access to the token string can decode the payload using base64 decoding instantly, even if they cannot forge a new signature.

Another common pitfall is forgetting to validate the algorithm header field during verification, which can leave systems vulnerable to algorithm substitution attacks if using third-party libraries. Writing your own verification logic forces you to hardcode the expected algorithm explicitly, eliminating that vulnerability vector entirely.

Now that you have built a working JWT implementation from scratch, you can expand your backend engineering skillset by exploring full-stack patterns in our MERN Stack with Microservices & Gen AI program or backend services in our Web Development with GoLang course.


jwt token from scratchnode js jwt authenticationrefresh token nodejsjson web token nodejsexpress jwt authnative crypto jwt signjwt middleware nodejs

Frequently asked questions