Article

Node.js Authentication in 2025: JWT vs Sessions vs OAuth

A practical guide to choosing and implementing authentication in Node.js SaaS — comparing stateless JWTs, server-side sessions, and OAuth 2.0 flows with real code examples.

Authentication is the most consequential infrastructure decision in a SaaS product. Getting it wrong means account takeovers, compliance failures, and emergency patches at 2 AM. This guide cuts through the theory and shows you what to actually build.

The Three Approaches

SessionsJWTOAuth / OIDC
StateServer-sideStatelessDelegated
StorageRedis / DBClient (cookie or localStorage)Provider handles it
RevocationInstantHard (need blocklist)Provider-dependent
Best forWeb appsAPI consumers, microservicesSocial login, SSO
ComplexityLowMediumMedium-High

Server-Side Sessions

Sessions store a random session ID in a cookie. The server maps that ID to user data in a store (Redis, PostgreSQL). Stateless JWTs are not inherently better — sessions are simpler, faster to revoke, and the session ID exposed to the client is meaningless without the server-side store.

Implementation with express-session + Redis

pnpm add express-session connect-redis ioredis
pnpm add -D @types/express-session
import session from "express-session";
import RedisStore from "connect-redis";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET!,  // min 32 random bytes
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,     // inaccessible to JS — prevents XSS theft
    secure: true,       // HTTPS only
    sameSite: "lax",    // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
  }
}));

// Login endpoint
app.post("/auth/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  req.session.userId = user.id;  // TypeScript: extend session type
  req.session.save();

  res.json({ ok: true });
});

// Auth middleware
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: "Not authenticated" });
  }
  req.user = await db.users.findById(req.session.userId);
  next();
};

// Logout
app.post("/auth/logout", (req, res) => {
  req.session.destroy(() => {
    res.clearCookie("connect.sid");
    res.json({ ok: true });
  });
});
// Extend express-session types
declare module "express-session" {
  interface SessionData {
    userId: number;
  }
}

Revocation is instant — delete the session from Redis and the user is logged out everywhere, immediately.

JWT Authentication

JWTs are base64-encoded, cryptographically signed tokens the client holds. The server verifies the signature without hitting a database. Use JWTs for API authentication where clients are not browsers (mobile apps, CLI tools, third-party integrations).

Secure JWT setup

pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_EXPIRY = "15m";   // Short — can't be revoked
const REFRESH_TOKEN_EXPIRY = "30d";  // Longer — stored in DB for revocation

interface TokenPayload {
  sub: number;  // user ID
  email: string;
}

export function signAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: ACCESS_TOKEN_EXPIRY });
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
}

// Login endpoint
app.post("/auth/login", async (req, res) => {
  const user = await authenticateUser(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Invalid credentials" });

  const accessToken = signAccessToken({ sub: user.id, email: user.email });
  const refreshToken = crypto.randomBytes(48).toString("hex");

  // Store refresh token hash in DB
  await db.refreshTokens.create({
    userId: user.id,
    tokenHash: await bcrypt.hash(refreshToken, 10),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
  });

  // Access token in memory/header; refresh token in httpOnly cookie
  res
    .cookie("refresh_token", refreshToken, { httpOnly: true, secure: true, sameSite: "strict" })
    .json({ accessToken });
});

// Auth middleware
const requireAuth = (req: Request, res: Response, next: NextFunction) => {
  const auth = req.headers.authorization;
  if (!auth?.startsWith("Bearer ")) return res.status(401).json({ error: "No token" });

  try {
    req.user = verifyAccessToken(auth.slice(7));
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
};

Critical: never store JWTs in localStorage. XSS attacks can steal them. Store the access token in memory (JS variable) and the refresh token in an httpOnly cookie.

OAuth 2.0 + OpenID Connect

Use OAuth for social login (Google, GitHub, Apple) and enterprise SSO (Okta, Azure AD). The user authenticates with the provider; you get back a verified identity.

Implementing Google OAuth with Passport.js

pnpm add passport passport-google-oauth20 express-session
pnpm add -D @types/passport @types/passport-google-oauth20
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  callbackURL: `${process.env.APP_URL}/auth/google/callback`
}, async (accessToken, refreshToken, profile, done) => {
  // Find or create user from Google profile
  const email = profile.emails?.[0]?.value;
  if (!email) return done(new Error("No email in Google profile"));

  let user = await db.users.findByEmail(email);
  if (!user) {
    user = await db.users.create({
      email,
      name: profile.displayName,
      googleId: profile.id,
      emailVerified: true  // Google verifies email
    });
  }

  done(null, user);
}));

passport.serializeUser((user: any, done) => done(null, user.id));
passport.deserializeUser(async (id: number, done) => {
  const user = await db.users.findById(id);
  done(null, user);
});

app.use(passport.initialize());
app.use(passport.session());

// Initiate OAuth flow
app.get("/auth/google",
  passport.authenticate("google", { scope: ["email", "profile"] })
);

// Handle callback from Google
app.get("/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/dashboard")
);

Managed auth providers

For most SaaS apps, consider Clerk, Auth0, or Lucia instead of implementing OAuth yourself:

// Clerk — drop-in auth with pre-built UI
import { clerkMiddleware, getAuth } from "@clerk/express";

app.use(clerkMiddleware());

app.get("/protected", (req, res) => {
  const { userId } = getAuth(req);
  if (!userId) return res.status(401).json({ error: "Not authenticated" });
  res.json({ userId });
});

Managed providers handle OAuth flows, email verification, MFA, session management, and admin dashboards. The cost (Clerk: $25+/mo at scale) is usually worth the engineering time saved.

Password Security

If you implement password-based auth, never use SHA-256 or MD5 for password hashing:

import bcrypt from "bcryptjs";  // pure JS, works everywhere

const BCRYPT_ROUNDS = 12;  // ~300ms on modern hardware — slow by design

// Registration
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);

// Login
const isValid = await bcrypt.compare(inputPassword, user.passwordHash);

For new projects, prefer Argon2 over bcrypt:

pnpm add argon2
import argon2 from "argon2";

const hash = await argon2.hash(password);
const isValid = await argon2.verify(hash, password);

Decision Guide

Are you building a web app with a browser frontend?
  → YES: Use sessions (express-session + Redis)

Do you need social login (Google, GitHub)?
  → YES: Add OAuth on top of sessions via Passport.js
          or use a managed provider (Clerk, Auth0)

Do you need to authenticate external API clients (mobile, CLI)?
  → YES: Add JWT-based API tokens (short-lived + refresh tokens)

Are you building microservices that can't share session store?
  → YES: Use JWTs between services, sessions for user-facing APIs

Do you need enterprise SSO (SAML, Okta)?
  → Use WorkOS, Clerk, or Auth0 — implementing SAML yourself is painful

Security Checklist

  • Passwords hashed with bcrypt (rounds ≥ 12) or Argon2
  • Session cookies are httpOnly, secure, sameSite=lax
  • JWTs stored in memory, not localStorage
  • Refresh tokens stored server-side with expiry
  • Rate limiting on login endpoints (5 attempts per 15 min)
  • Account lockout after repeated failures
  • Email verification before account activation
  • HTTPS enforced in production (app.set("trust proxy", 1) behind load balancer)
  • CSRF protection for session-based forms (csurf or double-submit cookie)
  • Dependency audit: pnpm audit --audit-level=high

FAQ

Should I use JWT or sessions for a Node.js SaaS app?
Sessions are simpler, more secure by default (no token exposure in JS), and easier to revoke. Use sessions unless you have a specific reason for JWTs — like authenticating third-party API consumers, or microservices that can't share a session store. Most SaaS apps do not need JWTs for their own web frontend.
How do I revoke a JWT before it expires?
You can't revoke a JWT without a server-side blocklist, which eliminates the stateless benefit. Options: use short expiry (15 minutes) with refresh tokens, maintain a blocklist in Redis (check on every request), or switch to sessions. If you need instant revocation, sessions are the better choice.
Is OAuth the same as authentication?
No. OAuth 2.0 is an authorization protocol — it grants third-party access to resources. OpenID Connect (OIDC), built on top of OAuth 2.0, adds the identity layer that makes it useful for authentication (logging in with Google, GitHub, etc.). When people say 'OAuth login,' they usually mean OAuth 2.0 + OIDC.