JWT (JSON Web Tokens) aren't bad. But JWT-everywhere—where you slap a JWT in every request and trust client-side verification—is. The internet collectively lost its mind around 2018, convinced that stateless tokens were the future and session databases were legacy baggage. They were wrong. Session cookies (HTTPOnly, Secure, SameSite) are simpler, safer, and just as scalable for web SaaS. Stateless JWTs can't revoke tokens instantly, can't detect compromised sessions, and require crypto math that gets wrong more often than it gets right. For Aidxn 2026, the pattern is clear: HTTPOnly session cookies for web dashboards, JWTs only for stateless service-to-service APIs or mobile apps where cookies don't work. Supabase Auth ships this hybrid pattern natively—and you should too.
The JWT Hype Cycle: Why It Happened
JWTs solve a real problem: you need to authenticate requests without hitting the database on every request. The JWT pitch: sign a token with a secret, send it in every request, verify the signature, boom—no session lookup. For distributed systems (microservices, edge functions, stateless APIs), that's brilliant. For web SaaS? It's cargo-cult architecture. The problem is that JWTs are stateless. Once issued, you can't revoke them. Your access token is valid until expiry, even if the user just deleted their account or changed their password. You end up inventing workarounds: blocklists (which are basically session databases), short expiry times (which require refresh token refreshes, which feels like spinning), and logout-requires-server-call (which breaks the "stateless" promise). The cookie-sceptics say "session databases don't scale." They're wrong. A session table in Postgres scales to billions of rows. Redis caches sessions in memory. You're not inventing a custom session store—you're using infrastructure you already have. And unlike JWTs, when a session is compromised, you delete it and the user is logged out immediately.
JWT vs Session Cookies: The Real Trade-offs
JWTs (signed, not encrypted)
Pros: Stateless. No database lookup per request. Works across subdomains (via Authorization header). Good for APIs and mobile apps. Token is self-contained and verifiable.
Cons: Can't revoke without a blocklist. Compromised tokens stay valid until expiry. Requires crypto verification on every request. Larger than session IDs (more bandwidth). User claims are static until token expires (if user's role changes, they don't see the change until re-login).
Session Cookies (HTTPOnly + Secure + SameSite)
Pros: Instantly revocable. Browser handles storage (no JS access, can't be stolen via XSS). Automatic CSRF protection via SameSite. Simpler: session lookup is just a table scan. Supports dynamic claims (user changes role, next request sees new role immediately). Smaller than JWTs (less bandwidth).
Cons: Requires a database (session table or Redis). Doesn't work cross-domain without extra work. Slightly more latency (one session lookup per request). Requires HTTPS and proper cookie flags to be secure.
For web SaaS, session cookies win. For APIs and mobile, JWTs win.
Why Stateless JWTs Fail in Production
1. You Can't Revoke Them (Without a Blocklist)
User logs out. Their JWT is still valid. You add their token to a blocklist. Congratulations, you've invented a session database. The only difference: your blocklist is O(1) lookup (hash table) instead of O(log n) (database), but you've added complexity and you still need to garbage-collect expired tokens from the blocklist.
2. Password Changes Don't Take Effect Immediately
User's password is compromised. They change it. But their JWT is still valid. The attacker with the old token can still access the system for the next hour (or however long your token expiry is). Supabase and Auth0 work around this by invalidating sessions on password change, but again: you're back to maintaining state.
3. Role Changes Require Token Refresh
User gets promoted. Admin updates their role from "viewer" to "editor" in the database. But the JWT they're holding says "viewer." You have two choices: (a) short expiry time (refresh every 5 minutes, which is painful), or (b) query the database to verify the user's current role (which defeats the stateless promise). Sessions don't have this problem: next request, you look up the user's current role and they see the change instantly.
4. They're Not Encrypted, Just Signed
A JWT in the wild is readable base64. Anyone can decode eyJhbGciOiJIUzI1NiJ9.eyJ... and see the payload. If you put sensitive data in a JWT (user ID, email, subscription tier), it's exposed to anyone with the token. You should never put passwords, API keys, or PII in a JWT. Cookies, by contrast, are opaque to the client (HTTPOnly means JavaScript can't read them). The server is the only thing that knows what's in the session.
Supabase Auth: The Hybrid Pattern That Works
Supabase Auth ships a hybrid: session cookies for web (via HTTPOnly, Secure, SameSite flags), JWTs for APIs and mobile. Here's what Supabase does:
Web Browser: Session Cookie
// After login, Supabase sets this HTTP-only cookie automatically:
Set-Cookie: sb-access-token=eyJhbGc...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600;
Set-Cookie: sb-refresh-token=eyJhbGc...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800;
// Your browser never touches the cookie. Fetch sends it automatically.
// Logout deletes the cookie. Revocation is instant.
API & Mobile: JWT in Authorization Header
// Mobile app or API client:
GET /api/data HTTP/1.1
Authorization: Bearer eyJhbGc.eyJz...
// JWTs work here because stateless is a feature, not a bug.
Server-Side Verification: Hybrid Trust
// src/lib/supabase.ts
import { createServerClient } from '@supabase/ssr';
export const supabase = createServerClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
// Automatic: cookies are sent by browser, read by server
return request.headers.get('cookie') || '';
},
},
}
);
// In your route handler:
const { data, error } = await supabase.auth.getSession();
// Supabase verifies the session token is valid and not revoked.
How to Ship Sessions the Right Way
1. Use HTTPS Only
Cookies without HTTPS are naked. Set the Secure flag so browsers only send the cookie over HTTPS. On localhost, you can disable this for testing, but never in production.
2. Set HTTPOnly (No JavaScript Access)
If an XSS vulnerability lets an attacker run JavaScript on your page, they can't steal the session cookie (because JavaScript can't read HTTPOnly cookies). They can steal JWTs in Authorization headers because JavaScript can read request headers. Advantage: cookies.
3. Set SameSite (CSRF Protection)
// Use SameSite=Lax (default for modern browsers)
// Browsers only send the cookie on same-site requests
// Cross-site requests (attacker.com → yourapp.com) don't include the cookie
// Prevents CSRF attacks automatically
Set-Cookie: session=abc123; SameSite=Lax;
4. Keep Sessions Short-Lived (30 min to 1 hour)
Session compromise is possible. Short expiry limits the window. Use a refresh cookie (longer expiry, same domain) to extend sessions without re-login. When the user closes the browser, refresh token expires and they're logged out. This is the Supabase pattern.
5. Revoke Immediately on Logout
// src/pages/auth/logout.ts
import { supabase } from '@/lib/supabase';
export async function POST(request: Request) {
const { error } = await supabase.auth.signOut();
if (error) {
return Response.json({ error: error.message }, { status: 400 });
}
// Supabase deletes the session. Cookie is cleared.
return Response.redirect('/login', 302);
}
6. Invalidate on Sensitive Changes
When a user changes their password, email, or permission level, invalidate their session. Force them to log in again. This is the only reliable way to ensure they see the change immediately.
When to Use JWTs (The Right Cases)
1. Mobile Apps (No Cookie Storage)
iOS and Android don't have cookies in the same way browsers do. JWTs in Authorization headers are the standard.
2. Stateless APIs (Microservices)
If you're building a distributed system where each service is stateless and you can't share a session database, JWTs work. Just accept that you can't revoke instantly.
3. Edge Functions & Cloudflare Workers
If your auth happens at the edge (before hitting your origin), JWTs are simpler because you can verify the signature without a database lookup. Supabase Edge Functions use this pattern.
4. Third-Party API Keys
If a user generates an API key to use your API externally, that's a JWT (or an opaque token that maps to a session). The key doesn't expire until the user explicitly revokes it.
Rule of thumb: If you have a database and a browser, use session cookies. If you don't, use JWTs.
Six Auth Token FAQs
Do I need both access and refresh tokens?
For web apps with session cookies, no. The session cookie is your access token. For JWTs, yes. Issue a short-lived access token (5 min) and a longer-lived refresh token (7 days). If the access token leaks, the attacker has a narrow window. The user requests a new access token using the refresh token (which is stored securely, e.g., HTTPOnly cookie). This is the OAuth 2.0 standard. Supabase does this by default.
Can I use JWTs without a database?
Yes, if you accept that you can't revoke tokens instantly. Google's JWT tokens work this way: they're signed, you verify the signature, and they're valid until expiry. But they issue short-lived tokens (1 hour) to limit the damage if one leaks. For a SaaS product with user-controlled sessions, a database is necessary.
What if I need to authenticate across subdomains?
Cookies are domain-scoped. If your app is split across app.example.com and api.example.com, a cookie set on example.com (with Domain=example.com) is sent to both subdomains automatically. JWTs in Authorization headers are simpler for this (send the same header to all subdomains). But neither is a deal-breaker; cookies are still the safer default.
How do I refresh a session without logging out?
With session cookies, you don't refresh manually. When the session expires, the user's next request fails, you redirect them to login. With refresh tokens (JWTs or opaque tokens), you make a background request to /auth/refresh and get a new access token without the user knowing. Supabase handles this transparently: the client library refreshes the token in the background if it's about to expire.
Is it safe to store a JWT in localStorage?
No. localStorage is readable by JavaScript, so any XSS attack can steal it. Use HTTPOnly cookies instead. If you must use localStorage (e.g., browser extension), accept that XSS = compromised. This is why session cookies are safer: XSS can't read them.
Can I use session cookies for my API?
Only if clients are browsers. Mobile apps, CLIs, and third-party integrations can't use browser cookies. For APIs, JWTs or opaque API keys are standard. You could use session cookies if your API is only called by your own frontend, but that's uncommon.
The Bottom Line
JWTs were an innovation for distributed, stateless systems. They're not a universal replacement for sessions. For web SaaS, HTTPOnly session cookies are simpler, safer, and just as scalable. You get instant revocation, automatic CSRF protection, smaller token size, and dynamic claims. The tradeoff is a session table (which is trivial to operate) and one database lookup per request (which is negligible). Supabase Auth does this right: session cookies for browsers, JWTs for APIs and mobile. If you're building a web app, stop overthinking auth. Ship session cookies. Your future self will thank you when you don't have to debug token expiry issues at 2am. For more on passwordless flows with sessions, see magic links. Ready to build auth that scales? Check Aidxn Design's backend partnerships to ship production-grade authentication.