You're starting a new SaaS. You need auth. You google "JWT vs sessions" and thirty minutes later you're more confused than when you started — half the results say JWTs are the future of stateless auth, the other half say they're a footgun. Someone in a forum says you need OAuth. Someone else says OAuth is overkill.
Here's what I've learned building auth for several production systems: the right answer comes down to two questions — how many different client types need to authenticate, and how quickly you need to be able to revoke access? Everything else is secondary.
Three Patterns, Three Different Problems
Before the comparison, let's be precise about what we're comparing:
- Server-side sessions: a session ID stored in an httpOnly cookie; the server keeps the actual session data (in Redis, PostgreSQL, or memory)
- JWTs (JSON Web Tokens): a self-contained signed token that the client stores and presents with every request; the server needs no state to verify it
- OAuth 2.0 + OIDC: a delegation protocol — users authorize your app to act on their behalf, or third-party clients authorize access to your API
These solve different problems. OAuth is often conflated with "login with Google" but it's really about delegation and third-party access. You can use sessions or JWTs as the underlying mechanism inside an OAuth flow.
Server-Side Sessions
How they work
The server creates a session record when a user logs in and stores it in a backing store — typically Redis for performance, PostgreSQL for durability. The client gets an opaque session ID in an httpOnly cookie. Every request, the server looks up the ID, retrieves the session data, and proceeds.
// lib/session.ts — the core of a session store built on ioredis
import Redis from "ioredis";
import { randomBytes } from "crypto";
interface SessionData {
userId: string;
email: string;
role: "user" | "admin";
createdAt: number;
}
const redis = new Redis(process.env.REDIS_URL!);
const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days
export async function createSession(data: SessionData): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
await redis.setex(`session:${sessionId}`, SESSION_TTL, JSON.stringify(data));
return sessionId;
}
export async function getSession(sessionId: string): Promise<SessionData | null> {
const raw = await redis.get(`session:${sessionId}`);
if (!raw) return null;
return JSON.parse(raw) as SessionData;
}
export async function deleteSession(sessionId: string): Promise<void> {
await redis.del(`session:${sessionId}`);
}
When sessions work well
Sessions are the right default for a web-only SaaS where you control all clients. They're simple: your session store is the single source of truth, and revocation is just deleting a record. User gets banned? Delete the session. User changes their password? Delete all sessions for that user. Security incident? Flush everything.
In vatnode.dev the dashboard uses server-side sessions backed by Redis. When a subscription lapses, the next page load reflects the change immediately — no waiting for a token to expire.
The real costs
You need a session store. If you're already running Redis for rate limiting or queues, this costs nothing extra. If you're not, you need a new piece of infrastructure. For a single-server app it's fine; for a horizontally scaled service you need the session store to be shared, which adds a network hop to every authenticated request.
The cookie approach also ties you to browsers. Native mobile apps don't handle cookies the same way, and cross-origin cookie policies can get painful quickly. If you're building a web app plus a mobile app plus a public API, sessions alone won't cover all three cleanly.
JWTs
How they work
The server signs a payload (user ID, role, expiry) with a secret key and hands it to the client. The client stores it (usually in memory, or localStorage for long-lived tokens) and sends it in the Authorization header. The server verifies the signature — no database lookup required.
// lib/jwt.ts
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
interface TokenPayload {
sub: string; // userId
email: string;
role: "user" | "admin";
plan: "free" | "paid";
}
export async function signToken(payload: TokenPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m") // Short expiry — this matters, see below
.sign(secret);
}
export async function verifyToken(token: string): Promise<TokenPayload> {
const { payload } = await jwtVerify(token, secret);
return payload as unknown as TokenPayload;
}
The stateless claim — and why it's overblown
The selling point for JWTs is "stateless auth — no database lookup on every request." That's true, and it genuinely matters for high-throughput APIs and microservices where you don't want a shared session store.
The problem is that "stateless" breaks the moment you need to revoke a token. You issued a JWT valid for 24 hours. The user gets compromised. You cannot invalidate that token — the server has no record of it. The attacker's token is still valid for the remainder of its lifetime.
The standard fix is a token blacklist: a Redis set of revoked token IDs (the jti claim). But now you're doing a Redis lookup on every request — exactly the thing sessions required. You've added JWT complexity without removing the stateful dependency.
// lib/jwt-blacklist.ts — the thing that partially defeats the "stateless" argument
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export async function revokeToken(jti: string, expiresAt: number): Promise<void> {
const ttl = expiresAt - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`jwt:revoked:${jti}`, ttl, "1");
}
}
export async function isRevoked(jti: string): Promise<boolean> {
return (await redis.exists(`jwt:revoked:${jti}`)) === 1;
}
You can mitigate the revocation problem by keeping JWTs short-lived — 15 minutes is a common choice — and issuing a separate refresh token for renewal. But that introduces its own complexity, which I'll cover next.
JWTs shine in microservices
Where JWTs genuinely earn their place: service-to-service auth in a microservices architecture. Service A calls Service B; Service B needs to know who the original user was. Rather than Service B querying a shared session store (tight coupling), Service A attaches the user's JWT and Service B verifies it independently with just the shared secret or public key. No shared database, no synchronous dependency.
If you're building a microservices backend or an API that will be consumed by clients you don't control, JWTs are a good fit.
The JWT + Refresh Token Pattern
slug="mvp-development"
text="Auth is one of the layers I build into every SaaS from scratch — sessions, OAuth, API keys, and GDPR-compliant account deletion. No tutorials, no copy-paste."
/>
Most production systems that use JWTs end up here: short-lived access tokens (15–60 minutes) plus a long-lived refresh token stored in an httpOnly cookie.
// lib/tokens.ts — the practical middle ground
import { signToken, verifyToken } from "@/lib/jwt";
import { randomBytes } from "crypto";
import { db } from "@/packages/db";
import { refreshTokens } from "@/packages/db/schema";
import { eq } from "drizzle-orm";
export async function issueTokenPair(
userId: string,
email: string,
role: "user" | "admin",
plan: "free" | "paid"
) {
// Short-lived access token — stateless verification, 15-minute window
const accessToken = await signToken({ sub: userId, email, role, plan });
// Long-lived refresh token — stored in DB, httpOnly cookie
const refreshToken = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 30 days
// Store enough data to re-issue the access token on rotation
// without querying the users table on every refresh
await db.insert(refreshTokens).values({
token: refreshToken,
userId,
email,
role,
plan,
expiresAt,
});
return { accessToken, refreshToken };
}
export async function rotateRefreshToken(oldToken: string) {
const record = await db.query.refreshTokens.findFirst({
where: eq(refreshTokens.token, oldToken),
});
if (!record || record.expiresAt < new Date()) {
throw new Error("Invalid or expired refresh token");
}
// Delete the old token (rotation — prevents replay attacks)
await db.delete(refreshTokens).where(eq(refreshTokens.token, oldToken));
return issueTokenPair(record.userId, record.email, record.role, record.plan);
}
This is a legitimate approach — the access token is stateless and fast to verify, and the refresh token stored server-side gives you the ability to revoke sessions. But notice what you've added: a database table for refresh tokens, rotation logic, a token endpoint, and now two different tokens your client must manage. You've re-introduced statefulness through the back door.
The tradeoff is worth it if you need both stateless verification for API performance and reliable revocation. For a typical web-only SaaS, this is more complexity than necessary — just use sessions.
OAuth 2.0 + OIDC
What it's actually for
OAuth 2.0 is a delegation protocol. It answers the question: "how does a third party get permission to act on a user's behalf?" The "Sign in with Google" button is just one use case — and the simplest one at that.
Where OAuth becomes essential:
- You're building a platform with a public API — other developers' apps will call your API as their users
- You have mobile clients that need secure token refresh without storing secrets
- You need to delegate specific scopes — "this app can read your orders but not create them"
- You're building a multi-tenant SaaS where workspace members need different access levels
When it's overkill
OAuth is not the right answer for a web app where you're just letting your own users log in. The protocol is designed for multi-party delegation. For first-party auth, the added complexity — authorization server, token endpoints, scope management, PKCE flows — is all cost with no benefit.
I've seen SaaS founders spend two weeks implementing full OAuth for their own login flow when sessions would have done the job in two days. Don't do this.
The clearest case where OAuth is genuinely required: a B2B client wants their ERP or reporting tool to pull data from their account on their users' behalf — without sharing admin credentials. That's exactly the delegation problem OAuth solves. Without it, the alternative is handing over a password, which defeats the point.
Auth services — the real tradeoff
Clerk, Auth0, and similar services handle sessions, OAuth, MFA, magic links, and user management out of the box. The developer experience is excellent. The tradeoff is real:
Cost at scale. Clerk's pricing is MAU-based. At 10,000 monthly active users you're paying $250/month for auth alone. Auth0 is similar. For an early-stage SaaS this is fine; for a high-volume B2C product this becomes a significant line item.
Vendor lock-in. Your user records, session state, and auth logic live in a third-party system. If they go down, your auth goes down. If they change pricing, you're migrating under pressure.
Reduced learning. If you call yourself a senior developer, outsourcing auth means you never build the intuition for how it works. That matters when something goes wrong at 2 AM.
My current preference: Better Auth — open-source, self-hosted, excellent TypeScript support, handles sessions, OAuth providers, magic links, and MFA in one library. It's what I use in my own SaaS projects, including vatnode.dev.
The Decision Matrix
There are four things to nail down before picking an approach:
| Question | Sessions | JWT + Refresh | OAuth |
|---|---|---|---|
| Web-only app, no mobile? | Best fit | Overengineered | Overkill |
| Mobile + web clients? | Cookie issues | Good fit | Good fit |
| Third-party API access? | Not suitable | Partial fit | Required |
| Need instant revocation? | Yes, trivial | Yes, via DB | Depends on impl |
| Microservices? | Shared store needed | Good fit | Good fit |
| Single-server, simple? | Best fit | Acceptable | Overkill |
What most SaaS apps should actually do
If you're building a web SaaS:
- Server-side sessions for your web app authentication. Backed by Redis if you have it, PostgreSQL if you don't. Cookie-based, httpOnly, Secure, SameSite=Lax.
- Separate API keys for programmatic API access — not JWTs. API keys are simpler to reason about, easier to scope, and trivial to revoke. Store a hash (SHA-256), never the raw key.
- OAuth only if you genuinely need it — third-party access, mobile deep linking, or building a platform where other developers' apps will call your API.
The JWTs-for-everything pattern is popular because tutorials use it. It's not wrong, but it adds complexity that most early-stage SaaS apps don't need and most founders don't fully understand until it bites them.
Gotchas I've Hit in Production
Forgetting to set SameSite on session cookies. The default in most frameworks is Lax, which protects against CSRF for top-level navigation requests but not for cross-origin API calls triggered by fetch. For a SaaS dashboard, Lax is fine; for a public API with a CORS policy, review this carefully.
Trusting JWT exp without checking clock skew. Server clocks drift. A token issued on one server instance might appear expired on another if the clocks differ by more than a few seconds. Libraries like jose accept a clockTolerance option — set it to 30–60 seconds.
Storing JWTs in localStorage. JWTs in localStorage are accessible to any JavaScript running on your page — including injected third-party scripts. Store access tokens in memory; store refresh tokens in httpOnly cookies. This eliminates the XSS attack vector that makes JWT storage in localStorage dangerous.
Session fixation on login. Always rotate the session ID after a successful login. If you use the same session ID before and after authentication, an attacker who captured the pre-auth session ID can elevate their access. Most session libraries handle this, but check.
Refresh token reuse attacks. If you're implementing refresh token rotation, check for token reuse detection — if a rotated (already-used) refresh token is presented, invalidate the entire session. This catches stolen refresh tokens where the attacker is using the token concurrently with the legitimate user.
What I Reach For
For a new web SaaS with no mobile client and no third-party API: Better Auth with PostgreSQL-backed sessions. Simple, auditable, reliable. I can revoke any session in one query, I know exactly where the data lives, and there's no token expiry logic to reason about.
For a SaaS with a developer-facing API: sessions for the dashboard, hashed API keys for API access, OAuth 2.0 only when a customer explicitly needs to authorize a third-party integration.
For a microservices backend or a product with mobile clients: JWTs with short expiry plus refresh tokens in httpOnly cookies, all managed through Better Auth's session plugin or a dedicated auth service.
The goal is matching the complexity of your auth implementation to the complexity of your actual requirements — not to the complexity of whatever tutorial you read most recently.
The wrong auth choice isn't the one with known weaknesses. It's the one whose complexity you didn't fully understand when you made it.
items={[
{
q: "Should I use JWT or sessions for a new SaaS app?",
a: "For a web-only SaaS with no mobile client and no third-party API access, server-side sessions are the better default. They are simpler to reason about, instant to revoke, and require no token expiry logic. JWTs earn their place when you have mobile clients, microservices where services need to verify identity without a shared session store, or a developer-facing API.",
},
{
q: "How do I handle JWT token revocation?",
a: "The standard fix is a Redis-based token blacklist: when a token is revoked, store its jti claim in Redis with a TTL matching the token's remaining lifetime. On each request, check the blacklist before proceeding. This partially defeats the stateless argument for JWTs — you now have a Redis lookup on every request — but it gives you revocation without waiting for the token to expire.",
},
{
q: "Is OAuth necessary for a SaaS login flow?",
a: "No. OAuth is a delegation protocol designed for multi-party access — third-party apps calling your API on behalf of users, or mobile deep linking. For first-party login on a web SaaS, OAuth adds authorization server complexity, token endpoint management, and PKCE flows without any benefit over sessions. A common mistake is spending two weeks implementing OAuth for a login form that sessions would have covered in two days.",
},
{
q: "What is the risk of storing JWTs in localStorage?",
a: "Any JavaScript running on your page — including third-party scripts — can read localStorage. An XSS attack or compromised analytics script can exfiltrate the token. Store access tokens in memory instead. Store refresh tokens in httpOnly cookies, which are inaccessible to JavaScript. This is the single most important JWT storage decision.",
},
{
q: "What auth library should I use in 2026?",
a: "I use Better Auth across my own SaaS projects, including vatnode.dev. It is open-source, self-hosted, handles sessions, OAuth providers, magic links, and MFA with strong TypeScript support. Unlike Clerk or Auth0, there is no MAU-based pricing that compounds as your user base grows, and your user data stays in your own database.",
},
]}
/>
If you're building a SaaS and need to get the auth foundation right from the start, I've built it across multiple production systems — from vatnode's subscription API to pi-pi.ee's multi-tenant B2B platform. The patterns above are what I actually use, not what sounds good in theory.
If you need a senior developer who can own the architecture end-to-end — get in touch. I'm available for freelance projects and long-term engagements.
Related reading:
- Production SaaS Checklist: Launch in 8 Weeks With Next.js — auth is just one item on the full checklist
- Stripe Webhooks Done Right: Production Architecture
- PostgreSQL Patterns I Use in Every Production Project — the database layer that backs session storage
- Better Auth documentation
- OWASP Session Management Cheat Sheet
- RFC 6749 — The OAuth 2.0 Authorization Framework
Top comments (0)