nodejs
26 May 2026
7 MIN READ

JWT vs Sessions: Pick the Right Auth Strategy (2026 Deep Dive)

JWT and sessions both handle authentication — but they make completely different trade-offs. Here's when to use each, what can go wrong, and how I implement auth in production NestJS projects.

JWT and sessions solve the same problem differently. Here's a practical breakdown of when to use each — with real trade-offs, security considerations, and code examples.


The Core Difference

Both JWT and sessions answer the same question: how does the server know who you are after you log in?

They just answer it in completely different ways.

SESSION FLOW:
User logs in
      ↓
Server creates session in database
      ↓
Server sends session ID to client (cookie)
      ↓
Client sends session ID on every request
      ↓
Server looks up session ID in database
      ↓
Server knows who you are ✅

JWT FLOW:
User logs in
      ↓
Server creates signed token containing user data
      ↓
Server sends token to client (localStorage or cookie)
      ↓
Client sends token on every request
      ↓
Server verifies signature — no database lookup needed
      ↓
Server knows who you are ✅

One approach stores state on the server. The other stores it in the token itself.


How JWT Works

A JWT has three parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.abc123signature
      ↑                        ↑                        ↑
   Header                   Payload                  Signature
(algorithm)            (your user data)          (tamper-proof)

The payload is just base64 encoded — not encrypted. Anyone can decode it.

// Decode the payload (no secret needed)
const payload = JSON.parse(atob("eyJzdWIiOiJ1c2VyXzEyMyJ9"));
// { sub: "user_123", role: "admin", iat: 1234567890 }

The signature is what makes it secure — it's created using your secret key. If anyone tampers with the payload, the signature won't match and the token is rejected.


JWT Implementation in NestJS

npm install @nestjs/jwt passport-jwt @nestjs/passport
// auth.module.ts
@Module({
  imports: [
    JwtModule.registerAsync({
      useFactory: (config: ConfigService) => ({
        secret: config.get("JWT_SECRET"),
        signOptions: { expiresIn: "15m" }, // short-lived access token
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  async login(dto: LoginDto) {
    const user = await this.prisma.user.findUnique({
      where: { email: dto.email },
    });

    if (!user || !(await bcrypt.compare(dto.password, user.password))) {
      throw new UnauthorizedException("Invalid credentials");
    }

    // Access token — short lived
    const accessToken = this.jwtService.sign({
      sub: user.id,
      email: user.email,
      role: user.role,
    });

    // Refresh token — long lived, stored in DB
    const refreshToken = this.jwtService.sign(
      { sub: user.id },
      { expiresIn: "7d" },
    );

    // Store hashed refresh token in database
    await this.prisma.user.update({
      where: { id: user.id },
      data: {
        refreshToken: await bcrypt.hash(refreshToken, 10),
      },
    });

    return { accessToken, refreshToken };
  }

  async refreshTokens(userId: string, refreshToken: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
    });

    if (!user?.refreshToken) {
      throw new UnauthorizedException("Access denied");
    }

    // Verify refresh token matches stored hash
    const matches = await bcrypt.compare(refreshToken, user.refreshToken);
    if (!matches) throw new UnauthorizedException("Access denied");

    // Issue new tokens
    return this.login({ email: user.email, password: "" });
  }

  async logout(userId: string) {
    // Invalidate refresh token
    await this.prisma.user.update({
      where: { id: userId },
      data: { refreshToken: null },
    });
  }
}
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.get("JWT_SECRET"),
    });
  }

  async validate(payload: any) {
    return {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
    };
  }
}

Session Implementation in NestJS

npm install express-session connect-pg-simple @types/express-session
// main.ts
import * as session from "express-session";
import * as connectPg from "connect-pg-simple";

const PgStore = connectPg(session);

app.use(
  session({
    store: new PgStore({
      conString: process.env.DATABASE_URL,
      tableName: "sessions",
    }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
      httpOnly: true, // prevents XSS
      secure: true, // HTTPS only
      sameSite: "strict", // prevents CSRF
    },
  }),
);
// auth.service.ts — session-based login
async login(dto: LoginDto, session: Record<string, any>) {
  const user = await this.prisma.user.findUnique({
    where: { email: dto.email },
  });

  if (!user || !(await bcrypt.compare(dto.password, user.password))) {
    throw new UnauthorizedException('Invalid credentials');
  }

  // Store user data in session
  session.userId = user.id;
  session.role = user.role;

  return { message: 'Logged in successfully' };
}

async logout(session: Record<string, any>) {
  return new Promise((resolve, reject) => {
    session.destroy((err) => {
      if (err) reject(new InternalServerErrorException());
      resolve({ message: 'Logged out' });
    });
  });
}

Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.

The Real Trade-offs

JWT Advantages

No database lookup on every request:

Session: every request → database query (check if session valid)
JWT:     every request → signature verification (CPU only, no DB)

For high-traffic APIs this matters. 10,000 requests/second with sessions = 10,000 DB queries/second just for auth.

Works perfectly for microservices:

Client → Service A → verifies JWT locally ✅
       → Service B → verifies JWT locally ✅
       → Service C → verifies JWT locally ✅

Each service verifies the token independently. No shared session store needed.

Stateless — scales horizontally:

Load Balancer
    ↓       ↓       ↓
Server1  Server2  Server3

Any server can verify any JWT. Sessions require sticky sessions or a shared store.


JWT Disadvantages

You cannot invalidate a token before it expires:

// User changes password — old JWT still works until expiry ❌
// User gets banned — their JWT still works until expiry ❌
// User logs out — their JWT still works until expiry ❌

This is the biggest problem with JWT. The common workaround:

// Token blocklist in Redis
async logout(token: string) {
  const decoded = this.jwtService.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);

  // Blocklist token until it naturally expires
  await this.redis.setex(`blocklist:${token}`, ttl, '1');
}

// Check blocklist in every request
async validateToken(token: string) {
  const isBlocklisted = await this.redis.get(`blocklist:${token}`);
  if (isBlocklisted) throw new UnauthorizedException('Token invalidated');
  // ... rest of validation
}

But now you're doing a Redis lookup on every request anyway — partially defeating the stateless advantage.

Tokens can be stolen:

If someone gets your JWT — from localStorage, logs, or a man-in-the-middle attack — they have full access until expiry. Sessions can be invalidated server-side immediately.


Session Advantages

Instant revocation:

// Ban a user → delete their session → they're logged out immediately ✅
await this.prisma.session.deleteMany({ where: { userId: bannedUserId } });

Sensitive data never leaves the server:

JWT: role, permissions, email → all in the token → readable by anyone
Session: session ID only → actual data stays on server → more secure

Simpler mental model:

No access tokens, refresh tokens, token rotation, or expiry management. One session, one source of truth.


Session Disadvantages

Database dependency:

Every request hits the session store. If your session store goes down, authentication fails for all users.

Doesn't work well across domains:

app.yoursite.com → session cookie ✅
api.yoursite.com → different domain → cookie not sent ❌
mobile app       → no cookies → sessions don't apply ❌

Sessions work great for server-rendered apps. They're painful for APIs consumed by mobile apps or third-party clients.


When to Use Which

Use JWT when:
✅ Building a REST API consumed by mobile apps
✅ Microservices architecture
✅ Multiple domains or subdomains
✅ High-traffic APIs where DB queries are expensive
✅ Third-party clients need to authenticate

Use Sessions when:
✅ Traditional server-rendered web app (same domain)
✅ You need instant token revocation
✅ Banking, healthcare, or high-security applications
✅ Simple CRUD app with a small user base
✅ Admin panels where security > performance

What I Use in Production

For most of my NestJS freelance projects — which are REST APIs consumed by React or Flutter frontends — I use JWT with refresh tokens:

Access token  → 15 minutes expiry → sent in Authorization header
Refresh token → 7 days expiry     → stored in httpOnly cookie
// The access token is short-lived — limits damage if stolen
// The refresh token is httpOnly — not accessible via JavaScript
// Refresh tokens are stored hashed in DB — can be revoked instantly

// When access token expires:
// Frontend calls /auth/refresh with the cookie
// Server verifies refresh token against DB hash
// Issues new access token + rotates refresh token

This gives you:

  • Stateless access token verification (fast)
  • Ability to revoke sessions via refresh token invalidation
  • XSS protection via httpOnly cookie for refresh token
  • Short damage window if access token is stolen (15 min)

Common Security Mistakes

// ❌ Never store JWT in localStorage — XSS vulnerable
localStorage.setItem('token', accessToken);

// ✅ Store refresh token in httpOnly cookie
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

// ❌ Never put sensitive data in JWT payload
const token = jwt.sign({
  userId, password, creditCard // ❌ readable by anyone
});

// ✅ Only put non-sensitive identifiers
const token = jwt.sign({
  sub: userId,
  role: user.role, // ✅ not sensitive
});

// ❌ Never use weak secrets
JWT_SECRET=secret123 // ❌ brute-forceable

// ✅ Use a strong random secret
JWT_SECRET=openssl rand -hex 64 // ✅ 512-bit random string

Final Recommendation

ScenarioUse
REST API + mobile appJWT + refresh tokens
MicroservicesJWT
Server-rendered web appSessions
High-security app (banking)Sessions
Simple CRUD appSessions (simpler)
Multiple frontends/domainsJWT

There is no universally correct answer. Pick based on your architecture, not hype.

JWT is not more secure than sessions. Sessions are not more scalable than JWT. Each has a context where it wins.


For the full NestJS setup this fits into, see NestJS + Prisma + PostgreSQL: My Freelance Backend Stack.

If you're also handling high-traffic auth requests, combining JWT with Redis caching for session blocklists keeps everything fast.

Need auth implemented correctly for your project? Let's talk.


Have questions about the implementation? Drop a message

Mentioned Technologies

#nodejs#nestjs#backend#security#jwt#authentication#typescript#webdev

Frequently Asked Questions

Sessions store state on the server and reference it via a cookie, while JWTs are self-contained tokens that carry claims and are verified without a server-side lookup.

Neither is inherently more secure; each has trade-offs. Sessions are easier to revoke, while JWTs scale statelessly but need careful handling of expiry and storage.

Use sessions when you need easy revocation and server-side control; use JWTs for stateless, distributed systems and APIs where avoiding a session store matters.

Built this in production

Digital Bank & Secure Payment Platform

FinTech / Digital Banking System — read the case study

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts