Sessions vs JWT

Two Ways to Remember a Logged-in User

Server sessionsJWT (JSON Web Token)
What the client holdsA random session id in a cookieA signed token with user data (claims)
Where state livesServer (Redis / DB)In the token itself
Checking a requestLook up the session storeVerify the signature, no lookup
Logout / revokeEasy: delete the sessionHard: token valid until it expires
Good forClassic web appsAPIs used by mobile apps, SPAs, other services

Most NestJS APIs use short-lived access tokens (JWT, 15 minutes) plus long-lived refresh tokens (7–30 days) that can be revoked. That combines the speed of JWT with the control of sessions.

What is Inside a JWT

A JWT is header.payload.signature, each part base64url-encoded. The payload is readable by anyone; the signature only proves it was not changed. Never put passwords or secrets in it.

Example

Signing and verifying an HS256 JWT with only node:crypto. This is what @nestjs/jwt (via the jsonwebtoken library) does for you:

import { createHmac, timingSafeEqual } from 'node:crypto';

const b64 = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url');

function sign(payload: Record<string, unknown>, secret: string, expiresInSec: number, now: number): string {
  const body = { ...payload, iat: now, exp: now + expiresInSec };
  const data = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64(body);
  return data + '.' + createHmac('sha256', secret).update(data).digest('base64url');
}

function verify(token: string, secret: string, now: number): Record<string, unknown> {
  const [h, p, s] = token.split('.');
  const expected = createHmac('sha256', secret).update(h + '.' + p).digest();
  const given = Buffer.from(s, 'base64url');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw new Error('invalid signature');
  const payload = JSON.parse(Buffer.from(p, 'base64url').toString());
  if (payload.exp <= now) throw new Error('jwt expired');
  return payload;
}

const NOW = 1768471200;                          // 2026-01-15T10:00:00Z in seconds
const token = sign({ sub: 12, email: '[email protected]', roles: ['customer'] }, 'dev-secret', 900, NOW);
console.log('token parts:', token.split('.').map((x) => x.length));
console.log('verified:', verify(token, 'dev-secret', NOW + 60));

const tampered = token.split('.');
tampered[1] = b64({ sub: 12, email: '[email protected]', roles: ['admin'], iat: NOW, exp: NOW + 900 });
for (const [label, t, at] of [['tampered', tampered.join('.'), NOW], ['expired', token, NOW + 901]] as const) {
  try { verify(t, 'dev-secret', at); } catch (e) { console.log(label + ':', (e as Error).message); }
}

Output

token parts: [ 36, 123, 43 ]
verified: {
  sub: 12,
  email: '[email protected]',
  roles: [ 'customer' ],
  iat: 1768471200,
  exp: 1768472100
}
tampered: invalid signature
expired: jwt expired