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
| Sessions | JWT | OAuth / OIDC | |
|---|---|---|---|
| State | Server-side | Stateless | Delegated |
| Storage | Redis / DB | Client (cookie or localStorage) | Provider handles it |
| Revocation | Instant | Hard (need blocklist) | Provider-dependent |
| Best for | Web apps | API consumers, microservices | Social login, SSO |
| Complexity | Low | Medium | Medium-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 (
csurfor double-submit cookie) - Dependency audit:
pnpm audit --audit-level=high