Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. JWT vs Sessions vs OAuth: Which to Use for SaaS Auth

Iuriiย Compares: JWT vs Sessions vs OAuth: Which to Use for SaaS Auth

Two questions determine the right auth pattern โ€” here's how to answer them

May 14, 2026ยท 15 min read

JWT vs sessions vs OAuth for SaaS: token invalidation, mobile clients, refresh token rotation, and a decision matrix to pick the right auth pattern.

Stack

Next.jsTypeScriptNode.js

Libraries

Better Auth

Databases

PostgreSQLRedis

Topics

SaaSAuthArchitectureSecurity
JWT vs Sessions vs OAuth: Which to Use for SaaS Auth

On this page

  • Three Patterns, Three Different Problems
  • Server-Side Sessions
  • How they work
  • When sessions work well
  • The real costs
  • JWTs
  • How they work
  • The stateless claim โ€” and why it's overblown
  • JWTs shine in microservices
  • The JWT + Refresh Token Pattern
  • OAuth 2.0 + OIDC
  • What it's actually for
  • When it's overkill
  • Auth services โ€” the real tradeoff
  • The Decision Matrix
  • What most SaaS apps should actually do
  • Gotchas I've Hit in Production
  • What I Reach For

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

Related service

MVP Development

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.

More about this service โ†’

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:

QuestionSessionsJWT + RefreshOAuth
Web-only app, no mobile?Best fitOverengineeredOverkill
Mobile + web clients?Cookie issuesGood fitGood fit
Third-party API access?Not suitablePartial fitRequired
Need instant revocation?Yes, trivialYes, via DBDepends on impl
Microservices?Shared store neededGood fitGood fit
Single-server, simple?Best fitAcceptableOverkill

What most SaaS apps should actually do

If you're building a web SaaS:

  1. 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.
  2. 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.
  3. 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.

Frequently Asked Questions

+
Should I use JWT or sessions for a new SaaS app?
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.
+
How do I handle JWT token revocation?
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.
+
Is OAuth necessary for a SaaS login flow?
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.
+
What is the risk of storing JWTs in localStorage?
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.
+
What auth library should I use in 2026?
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
Iurii Rogulia

Working on something like this?

MVP Development

Auth is one of the foundations I build into every SaaS from day one โ€” sessions, OAuth providers, API keys, and GDPR-compliant account deletion included.

More about this service

Relevant client work

View all projects
vatnode โ€” EU VAT Validation API
vatnode โ€” EU VAT Validation API
January 19, 2026
vatnode โ€” EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

HTPBE? โ€” Has This PDF Been Edited?
HTPBE? โ€” Has This PDF Been Edited?
September 25, 2024
HTPBE? โ€” Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

HTPBE? โ€” Internal Admin Dashboard
HTPBE? โ€” Internal Admin Dashboard
March 15, 2026
HTPBE? โ€” Internal Admin Dashboard

Role-gated admin dashboard for the HTPBE? SaaS platform โ€” real-time KPIs, per-user quota tracking, and a zero-dependency bar chart, all server-rendered via

What clients say

โ€œ

I had a validated idea and a deadline tied to an accelerator demo day, but no product. Iurii helped me cut the scope down to what actually needed to exist for launch and pushed back hard on the features I thought I needed but didn't. We shipped a working MVP in six weeks with real users on it by demo day. The honest scoping at the start is what saved the timeline โ€” it would have been easy to let me build twice as much and miss the date.

Aino Virtanen ๐Ÿ‡ซ๐Ÿ‡ฎ

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries ๐Ÿ‡ณ๐Ÿ‡ฑ

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks
January 19, 2026ยท 17 min
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks

40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security โ€” with honest 8-week build estimates.

Stack

Next.jsTypeScript

Libraries

Drizzle ORMBetter AuthBullMQZodReact Email

Databases

PostgreSQLRedis

Services

StripeVercelResendSentry

Topics

SaaSArchitectureAuth
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026ยท 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026ยท 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, BullMQ queue, signature verification in Next.js App Router โ€” with full

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQDrizzle ORMioredis

Databases

PostgreSQLRedis

Services

Stripe

Topics

SaaSWebhooksArchitecturePayments
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types
August 4, 2025ยท 13 min
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types

Turborepo monorepo with Next.js and Hono, sharing one set of TypeScript types across frontend and backend.

Stack

Next.jsTypeScriptHonoTurborepoNode.jsDocker

Libraries

Drizzle ORMZod

Databases

PostgreSQLRedis

Services

Vercel

Topics

ArchitectureMonorepoSaaS
API Key Management for a Public SaaS API
August 5, 2026ยท 13 min
API Key Management for a Public SaaS API

API key management for a public SaaS: hashing keys at rest, prefix + last-4 display, fail-closed validation, and revocation โ€” plus the scoping, rotation, and

Stack

TypeScriptNode.jsHono

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSAPIAuthSecurity