Authentication Patterns for Modern Web Apps: Sessions, JWTs, and OAuth
Authentication is one of those things that seems simple until you actually build it. User signs up, gets a token, stays logged in. How hard can it be? Then you hit session invalidation, token storage, refresh flows, cross-domain auth, and the dozen ways your implementation can leak credentials or leave users locked out.
We have implemented authentication across every product we have shipped — from MindHyv where entrepreneurs manage clients and bookings behind a login wall, to LancerSpace where freelancers access proposals, invoices, and CRM data that absolutely must stay private. Each project taught us something about which patterns work, when, and why.
This is a practical guide to the authentication patterns we actually use, with real code and honest tradeoffs.
Session-Based Authentication
Session-based auth is the oldest pattern on the web, and it still works well. The server creates a session when the user logs in, stores it (in memory, a database, or Redis), and sends back a session ID as an HTTP-only cookie. Every subsequent request includes that cookie, and the server looks up the session to identify the user.
// Express session setup — simple and battle-tested
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
}));
The biggest advantage of sessions is server-side control. If you need to log a user out immediately — they reported a compromised account, they violated terms of service, their subscription expired — you delete the session from your store and they are logged out on their next request. No waiting for a token to expire.
The biggest disadvantage is server state. Every active user has a session record on your server. If you are running multiple server instances, you need a shared session store like Redis or a database. For most SaaS applications, this is not a problem — Redis handles millions of sessions without breaking a sweat. But it is added infrastructure.
Sessions work best for traditional server-rendered applications and any app where immediate session revocation matters.

JSON Web Tokens (JWTs)
JWTs flip the model. Instead of storing session state on the server, you encode the user’s identity and permissions into a signed token that the client stores and sends with each request. The server verifies the signature and trusts the token’s contents without any database lookup.
// Generating a JWT with essential claims
import jwt from 'jsonwebtoken';
function generateTokens(user: { id: string; email: string; role: string }) {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived access token
);
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
The appeal of JWTs is statelessness. No session store, no database lookups on every request, easy horizontal scaling. The server just verifies the signature — a fast cryptographic operation — and it knows who the user is.
But JWTs come with real tradeoffs that a lot of tutorials gloss over.
You cannot revoke a JWT before it expires. If a user’s access token is valid for 15 minutes, they have access for 15 minutes regardless of what happens. You can work around this with a token blocklist, but then you have a server-side store again and you have lost the stateless benefit.
Token storage is a security minefield. Store the access token in localStorage and you are vulnerable to XSS attacks — any injected script can steal the token. Store it in a cookie and you need proper CSRF protection. The safest approach for browser apps is an HTTP-only cookie for the refresh token and an in-memory variable for the short-lived access token.
// Middleware to verify JWT on protected routes
function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
JWTs work best for API-to-API communication, microservices, and mobile apps where cookies are awkward. For browser-based SaaS apps, we almost always prefer session-based auth or a managed solution.
The Refresh Token Flow
Whether you use JWTs or sessions, refresh tokens solve the same problem: keeping users logged in without making them re-enter credentials, while limiting the damage window of a stolen token.
The pattern is straightforward. Issue a short-lived access token (15 minutes) and a long-lived refresh token (7-30 days). When the access token expires, the client uses the refresh token to get a new one. The refresh token is stored securely — HTTP-only cookie, device keychain — and the access token lives in memory.
// Client-side token refresh with automatic retry
async function fetchWithAuth(url: string, options: RequestInit = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${getAccessToken()}`,
},
});
if (response.status === 401) {
// Access token expired — try refreshing
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
});
if (refreshResponse.ok) {
const { accessToken } = await refreshResponse.json();
setAccessToken(accessToken);
// Retry original request with new token
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
} else {
// Refresh failed — redirect to login
window.location.href = '/login';
}
}
return response;
}
One critical detail: implement refresh token rotation. Every time a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it after the legitimate user already has, the server detects the reuse and invalidates the entire token family, forcing a re-login.

OAuth 2.0 and Social Login
OAuth 2.0 is not an authentication protocol — it is an authorization framework. But through OpenID Connect (OIDC), it becomes the standard way to implement “Sign in with Google/GitHub/Apple.”
The flow: your app redirects the user to the provider, the provider authenticates them and redirects back with an authorization code, your server exchanges that code for tokens, and you create a local session or JWT for your app.
We do not implement OAuth from scratch. Life is too short, and the security surface area is too large. We use Supabase Auth, which handles the entire OAuth flow, token management, and session handling out of the box.
// Supabase Auth — OAuth sign-in with Google
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.PUBLIC_SUPABASE_URL,
process.env.PUBLIC_SUPABASE_ANON_KEY
);
async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline', // Get refresh token from Google
prompt: 'consent', // Always show consent screen
},
},
});
}
// Handle the callback — exchange code for session
// src/routes/auth/callback/+server.ts (SvelteKit)
export async function GET({ url, locals }) {
const code = url.searchParams.get('code');
if (code) {
await locals.supabase.auth.exchangeCodeForSession(code);
}
throw redirect(303, '/dashboard');
}
For Vincelio, our creator-brand marketplace, we implemented Google and Instagram OAuth because the user base — LATAM influencers and brand managers — expected social login as a default. Manual email/password registration had a measurably higher drop-off rate during onboarding.
Magic Links
Magic links skip passwords entirely. The user enters their email, you send them a link with a one-time token, they click it, and they are logged in. No password to remember, forget, or have stolen in a breach.
// Supabase magic link implementation
async function sendMagicLink(email: string) {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) throw error;
return { message: 'Check your email for the login link' };
}
Magic links work well for apps with infrequent logins — a monthly reporting tool, an admin dashboard, a client portal. They reduce friction for users who would otherwise forget their password and go through a reset flow every time.
The downsides: they depend on email delivery speed and reliability, they do not work well for apps where users log in multiple times per day, and they create a security dependency on the user’s email account. If someone has access to your email, they have access to every app using magic links.
We used magic links for Trackelio during early beta testing. The user base was small, logins were infrequent, and eliminating password management reduced our support burden. As usage grew, we added Google OAuth as an alternative path.

Security Considerations That Actually Matter
Beyond choosing a pattern, there are security fundamentals that apply regardless of your approach.
Always use HTTPS. This is non-negotiable in 2026. Tokens and session IDs sent over HTTP are trivially interceptable.
Set proper cookie flags. HttpOnly prevents JavaScript access (stops XSS token theft). Secure ensures cookies only travel over HTTPS. SameSite=Lax prevents most CSRF attacks. These three flags together eliminate entire categories of attacks.
// Security headers for auth-related responses
const securityHeaders = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'self'; script-src 'self'",
};
Hash passwords with bcrypt or Argon2. Never SHA-256, never MD5, never plaintext. If you are using Supabase Auth, this is handled for you — Supabase uses bcrypt internally.
Rate limit login attempts. Without rate limiting, attackers can brute-force credentials. Five attempts per IP per minute is a reasonable starting point. Combine with exponential backoff on failed attempts.
Implement proper logout. For sessions, delete the session from the store. For JWTs, clear the tokens from the client and add the refresh token to a blocklist. A logout button that only clears localStorage is not a real logout.
What We Actually Use
For most projects we build, we use Supabase Auth. It gives us email/password, OAuth providers, magic links, phone auth, and proper session management without us having to implement or maintain any of it. The Row Level Security integration means auth and authorization are handled at the database level, which we cover in depth in our post on multi-tenant SaaS architecture.
For MindHyv, the all-in-one business platform, we use Supabase Auth with Google OAuth and email/password. Entrepreneurs need quick, repeated access to their dashboard — bookings, invoices, social posts — so magic links would add too much friction. OAuth plus traditional credentials covers the user base well.
For JustTheRip, our digital pack-opening platform, we needed auth that felt seamless for a younger, gaming-oriented audience. Social login with minimal friction was key — nobody wants to fill out a registration form when they are excited to open their first digital card pack.
The common thread across all of these: we do not roll our own auth unless there is a specific technical reason that managed solutions cannot cover. Auth is a solved problem. The best thing you can do is use a battle-tested solution and spend your engineering time on what makes your product unique.
If you are building something that needs authentication and you are not sure which pattern fits, reach out at [email protected]. We have been through this decision dozens of times and can help you pick the right approach for your specific product.