Episode 3 — NodeJS MongoDB Backend Architecture / 3.14 — Authentication and Authorization

3.14.d — JWT Authentication

In one sentence: A JSON Web Token (JWT) is a compact, self-contained, digitally signed string with three parts -- Header, Payload, Signature -- that lets a server verify identity without storing session state, using the jsonwebtoken library in Node.js.

Navigation: <- 3.14.c Session vs Token Authentication | 3.14.e -- Auth Middleware ->


1. What is JWT?

JWT (pronounced "jot") stands for JSON Web Token. It is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object.

Key properties:

  • Compact -- small enough to send in a URL, header, or cookie
  • Self-contained -- carries all necessary user info (no database lookup)
  • Signed -- the server can verify it was not tampered with
  • Not encrypted by default -- anyone can read the payload; only the server can verify it

2. JWT structure: Header.Payload.Signature

A JWT is three base64url-encoded segments separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NWFiYzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwMTIwMDAwMCwiZXhwIjoxNzAxMjg2NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│                                          │                                                                                                    │
└── Header (base64url)                     └── Payload (base64url)                                                                              └── Signature

Header

{
  "alg": "HS256",    // signing algorithm: HMAC + SHA-256
  "typ": "JWT"       // token type
}

Common algorithms:

AlgorithmTypeDescription
HS256SymmetricSame secret to sign and verify (simple, common)
HS384SymmetricStronger HMAC variant
RS256AsymmetricPrivate key signs; public key verifies (microservices)
ES256AsymmetricElliptic curve; smaller keys than RSA

Payload (claims)

{
  "userId": "65abc123",     // custom claim
  "role": "admin",          // custom claim
  "email": "admin@app.com", // custom claim
  "iat": 1701200000,        // issued at (auto-added)
  "exp": 1701286400         // expires at (24h later)
}

Registered claims (standard, optional):

ClaimNamePurpose
subSubjectUser identifier
iatIssued AtWhen the token was created (Unix timestamp)
expExpirationWhen the token expires (Unix timestamp)
nbfNot BeforeToken not valid before this time
issIssuerWho issued the token
audAudienceIntended recipient
jtiJWT IDUnique token identifier (for blacklisting)

Warning: The payload is only base64-encoded, not encrypted. Anyone can decode it:

// Anyone can read the payload!
const payload = JSON.parse(atob('eyJ1c2VySWQiOiI2NWFiYzEyMyJ9'));
// { userId: "65abc123" }

Never put sensitive data (passwords, credit card numbers) in the payload.

Signature

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

The signature ensures:

  • The token was created by someone who knows the secret
  • The header and payload were not modified after signing

3. Installing jsonwebtoken

npm install jsonwebtoken

4. Creating (signing) a token

const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET; // e.g., "k8$Lm9#pQ2xZ7..."

// Basic token creation
const token = jwt.sign(
  { userId: '65abc123', role: 'admin' },  // payload
  SECRET,                                  // secret key
  { expiresIn: '24h' }                    // options
);

console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOi...

expiresIn format options:

FormatMeaning
'30s'30 seconds
'15m'15 minutes
'2h'2 hours
'7d'7 days
'1y'1 year
36003600 seconds (number = seconds)

5. Verifying (decoding) a token

try {
  const decoded = jwt.verify(token, SECRET);
  console.log(decoded);
  // {
  //   userId: '65abc123',
  //   role: 'admin',
  //   iat: 1701200000,    // issued at
  //   exp: 1701286400     // expires at
  // }
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    console.log('Token has expired');
  } else if (err.name === 'JsonWebTokenError') {
    console.log('Token is invalid (tampered or malformed)');
  } else if (err.name === 'NotBeforeError') {
    console.log('Token not yet active');
  }
}

jwt.verify vs jwt.decode:

MethodVerifies signature?Checks expiry?Use case
jwt.verify(token, secret)YESYESAlways use this for authentication
jwt.decode(token)NONODebugging only; never for auth decisions

6. Token expiration and refresh tokens

The problem

Short-lived tokens are safer (less damage if stolen), but force frequent re-login. Long-lived tokens are convenient but risky.

The solution: access + refresh token pattern

┌─────────────────────────────────────────────────┐
│                  Token Lifecycle                  │
├─────────────────────────────────────────────────┤
│                                                  │
│  ACCESS TOKEN          REFRESH TOKEN             │
│  ─────────────         ──────────────            │
│  Short-lived: 15m      Long-lived: 7d            │
│  Sent with every       Sent only to refresh      │
│    API request           endpoint                 │
│  In memory / cookie    In httpOnly cookie         │
│  Contains user data    Contains only userId       │
│  Stateless             Stored in DB (revocable)   │
│                                                  │
└─────────────────────────────────────────────────┘

Refresh token flow

1. Login → server returns accessToken (15m) + refreshToken (7d)
2. Client uses accessToken for API calls
3. accessToken expires → 401 response
4. Client sends refreshToken to /api/refresh
5. Server verifies refreshToken, checks DB
6. Server issues NEW accessToken (+ optionally new refreshToken)
7. Client retries the original request

Implementation

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

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

// --- Generate token pair ---
function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user._id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user._id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

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

  const user = await User.findOne({ email });
  if (!user || !(await user.comparePassword(password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const { accessToken, refreshToken } = generateTokens(user);

  // Store refresh token in database (for revocation)
  user.refreshToken = refreshToken;
  await user.save();

  // Send refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/api/refresh',             // only sent to refresh endpoint
  });

  // Send access token in response body
  res.json({ accessToken, user });
});

// --- Refresh route ---
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  try {
    // 1. Verify the refresh token
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

    // 2. Check if token exists in database (not revoked)
    const user = await User.findOne({
      _id: decoded.userId,
      refreshToken: refreshToken,
    });
    if (!user) {
      return res.status(403).json({ error: 'Token revoked or user not found' });
    }

    // 3. Generate new tokens
    const tokens = generateTokens(user);

    // 4. Rotate refresh token (invalidate old one)
    user.refreshToken = tokens.refreshToken;
    await user.save();

    // 5. Set new refresh token cookie
    res.cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      path: '/api/refresh',
    });

    res.json({ accessToken: tokens.accessToken });
  } catch (err) {
    res.status(403).json({ error: 'Invalid refresh token' });
  }
});

// --- Logout route ---
router.post('/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (refreshToken) {
    // Remove refresh token from database
    await User.findOneAndUpdate(
      { refreshToken },
      { $unset: { refreshToken: 1 } }
    );
  }

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

7. Where to store tokens

StorageXSS Safe?CSRF Safe?Recommendation
httpOnly cookieYES (JS cannot read)Needs sameSitePreferred for web apps
localStorageNO (JS can read = XSS risk)YES (not auto-sent)Avoid for sensitive tokens
sessionStorageNO (same XSS risk)YESSlightly better (cleared on tab close)
In-memory (JS variable)ModerateYESGood for access tokens (lost on refresh)

Best practice for web apps:

Access token  → in-memory JavaScript variable (or short-lived httpOnly cookie)
Refresh token → httpOnly, secure, sameSite cookie

8. JWT secret management

# Generate a strong random secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Rules:

  1. Never hardcode the secret in source code
  2. Use environment variables (process.env.JWT_SECRET)
  3. Make it long and random (64+ characters)
  4. Different secrets for access and refresh tokens
  5. Rotate secrets periodically (with a grace period for existing tokens)

.env file (never commit):

JWT_ACCESS_SECRET=a1b2c3d4e5f6...long_random_string_here
JWT_REFRESH_SECRET=x9y8z7w6v5u4...different_long_random_string

9. Complete auth flow: register, login, protected route

// --- models/User.js ---
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  name:         { type: String, required: true },
  email:        { type: String, required: true, unique: true, lowercase: true },
  password:     { type: String, required: true, select: false }, // excluded by default
  role:         { type: String, enum: ['user', 'admin'], default: 'user' },
  refreshToken: { type: String, select: false },
}, { timestamps: true });

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

userSchema.methods.comparePassword = async function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

module.exports = mongoose.model('User', userSchema);
// --- middleware/auth.js ---
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  // Check Authorization header first, then cookie
  let token;
  if (req.headers.authorization?.startsWith('Bearer ')) {
    token = req.headers.authorization.split(' ')[1];
  } else if (req.cookies?.accessToken) {
    token = req.cookies.accessToken;
  }

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

  try {
    const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
    req.user = decoded; // { userId, role, iat, exp }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

module.exports = { authenticate };
// --- routes/auth.js ---
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const { authenticate } = require('../middleware/auth');

const router = express.Router();

// Register
router.post('/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;
    if (await User.findOne({ email })) {
      return res.status(409).json({ error: 'Email already registered' });
    }
    const user = await User.create({ name, email, password });
    const accessToken = jwt.sign(
      { userId: user._id, role: user.role },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    res.status(201).json({ accessToken, user });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Login
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email }).select('+password');
    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    const accessToken = jwt.sign(
      { userId: user._id, role: user.role },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken, user });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Protected route
router.get('/profile', authenticate, async (req, res) => {
  const user = await User.findById(req.user.userId);
  res.json({ user });
});

module.exports = router;

10. JWT vs opaque tokens

JWT (self-contained)Opaque Token
FormatBase64-encoded JSON + signatureRandom string (a8f3k2...)
Contains user data?Yes (in payload)No (just an identifier)
Server lookup needed?No (verify signature only)Yes (look up in database/cache)
SizeLarge (500+ bytes)Small (~32 bytes)
RevocationHard (needs blacklist)Easy (delete from store)
DebuggingEasy (decode and read)Hard (opaque string)
Best forStateless APIs, microservicesWhen revocation is critical

11. Key takeaways

  1. JWT has three parts: Header (algorithm) + Payload (claims) + Signature (verification).
  2. Use jwt.sign() to create and jwt.verify() to validate -- never use jwt.decode() for auth.
  3. The access + refresh token pattern balances security (short-lived) with UX (no constant re-login).
  4. Store refresh tokens in httpOnly cookies; store access tokens in memory or short-lived cookies.
  5. JWT secrets must be strong, random, and from environment variables -- never hardcoded.
  6. The payload is readable by anyone -- never put passwords or secrets in it.

Explain-It Challenge

Explain without notes:

  1. If a JWT payload is just base64-encoded (not encrypted), how does the server know it was not tampered with?
  2. Why should access tokens be short-lived (15 minutes) while refresh tokens can be longer (7 days)?
  3. An attacker steals a JWT from localStorage via an XSS attack. What can they do, and how would storing the token in an httpOnly cookie have prevented this?

Navigation: <- 3.14.c Session vs Token Authentication | 3.14.e -- Auth Middleware ->