Web Authentication Deep Dive: OAuth 2.0 vs JWT vs Sessions
A technical comparison of OAuth 2.0 flows, JWT structure and security, and session-based authentication. Learn when to use each approach, security tradeoffs, and implementation examples for modern web apps.
Authentication is one of those domains where βit worksβ and βitβs secureβ are two very different things. OAuth 2.0, JWT, and session-based auth all solve the problem of βwho is this user and should they have access?β β but they solve it differently, with different tradeoffs for scalability, security, and complexity.
This guide breaks down each approach technically, compares them on the axes that matter for real systems, and tells you when to reach for each one.
Session-Based Authentication: The Original Model
Session-based auth is the classic approach that powered most of the early web. Hereβs how it works:
- User submits credentials (username + password)
- Server verifies credentials, creates a session record in storage (database, Redis, memory)
- Server sends back a session ID in a
Set-Cookieheader - Browser stores the cookie and sends it automatically on subsequent requests
- Server looks up the session ID in storage on each request to verify the user
# Flask example β session-based auth
from flask import Flask, session, redirect, request
import secrets
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# In-memory session store (use Redis in production)
sessions = {}
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
if verify_credentials(username, password):
session_id = secrets.token_urlsafe(32)
sessions[session_id] = {'user': username, 'created_at': time.time()}
response = redirect('/dashboard')
response.set_cookie(
'session_id',
session_id,
httponly=True, # Prevents JS access
secure=True, # HTTPS only
samesite='Lax' # CSRF protection
)
return response
return 'Invalid credentials', 401
@app.route('/dashboard')
def dashboard():
session_id = request.cookies.get('session_id')
session_data = sessions.get(session_id)
if not session_data:
return redirect('/login')
return f"Welcome, {session_data['user']}"
Session Storage Options
| Storage | Pros | Cons |
|---|---|---|
| In-memory | Fast, simple | Lost on restart, single-process only |
| Database (PostgreSQL) | Persistent, queryable | Slower, requires DB hit per request |
| Redis | Fast, persistent, distributed | Infrastructure dependency |
| Sticky sessions (load balancer) | Simple with in-memory | Single point of failure, complicates scaling |
Session Security Concerns
Session fixation: An attacker can pre-set a session ID, trick the user into authenticating with it, then use that session. Fix: always regenerate the session ID on login.
Session hijacking: If a session cookie is stolen (via XSS or network interception), the attacker gains full access. Mitigations: HttpOnly, Secure, SameSite cookie flags; short session lifetimes; IP binding (with caveats).
CSRF: Session cookies are sent automatically, so a malicious site can trigger actions on behalf of a logged-in user. Fix: CSRF tokens or SameSite=Strict/Lax.
JSON Web Tokens (JWT): Stateless Auth
JWT shifts state from the server to the client. Instead of storing session data server-side, the server encodes user information into a signed token that the client stores and presents.
JWT Structure
A JWT has three Base64URL-encoded parts separated by dots:
header.payload.signature
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (claims)
{
"sub": "user_123",
"name": "Alice",
"role": "admin",
"iat": 1711468800, // issued at
"exp": 1711472400 // expires at (1 hour later)
}
// Signature (HMAC-SHA256 of header + payload using secret key)
HMACSHA256(base64(header) + "." + base64(payload), secret)
The server verifies the signature on each request β no database lookup needed.
JWT Implementation Example
// Node.js with jsonwebtoken
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET; // at least 256-bit random string
// Login endpoint β issue JWT
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.users.findOne({ username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{
sub: user.id,
username: user.username,
role: user.role
},
SECRET_KEY,
{
expiresIn: '1h',
issuer: 'myapp.com',
audience: 'myapp.com'
}
);
// Option 1: Return in response body (client stores in memory or localStorage)
res.json({ token });
// Option 2: Set as HttpOnly cookie (more secure)
// res.cookie('access_token', token, { httponly: true, secure: true });
});
// Protected route β verify JWT
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token' });
}
const token = authHeader.substring(7);
try {
const payload = jwt.verify(token, SECRET_KEY, {
issuer: 'myapp.com',
audience: 'myapp.com'
});
req.user = payload;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
JWT Security Pitfalls
The alg: none attack. Some older libraries accept tokens with algorithm set to βnoneβ β meaning no signature verification. Always specify accepted algorithms explicitly.
// WRONG β vulnerable to alg:none attack
jwt.verify(token, secret);
// RIGHT β explicitly specify algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
HS256 vs RS256. HS256 uses a shared symmetric secret. Any service that can verify tokens can also create them. RS256 uses a public/private keypair β services verify with the public key but only the auth server can issue tokens. Use RS256 for multi-service architectures.
Token revocation. Once issued, a JWT is valid until expiry β thereβs no built-in revocation mechanism. If a user logs out or is banned, their token still works until expiration. Solutions:
- Short expiry times (15 min) with refresh tokens
- Token blocklist (reintroduces state, partially defeats the purpose)
- JTI (JWT ID) claim with a revocation store
Sensitive data in payload. JWTs are Base64-encoded, not encrypted. Anyone who receives the token can decode the payload. Never store passwords, PII, or secrets in JWT claims.
Refresh Token Pattern
βββββββββββββββ login βββββββββββββββββββ
β Client β βββββββββββββΊ β Auth Server β
β β βββββββββββββ β β
β β access_token β β
β β (15 min) β β
β β refresh_tokenβ β
β β (7 days) β β
ββββββββ¬βββββββ βββββββββββββββββββ
β access_token (each request)
βΌ
βββββββββββββββββββ
β Resource API β
β (validates JWT β
β locally, no DB)β
βββββββββββββββββββ
When the access token expires, the client uses the refresh token (stored securely) to get a new access token. The refresh token can be stored in an HttpOnly cookie and rotated on each use.
OAuth 2.0: Delegated Authorization
OAuth 2.0 is often confused with authentication, but itβs primarily an authorization protocol. It answers βcan this app access this resource on behalf of this user?β rather than βwho is this user?β
The Four Flows (Grant Types)
1. Authorization Code Flow β the standard for web apps with a server-side component:
User β App β Authorization Server β User consents β Auth Server β
Redirects back to App with code β App exchanges code for tokens
# Step 1: Redirect user to authorization endpoint
@app.route('/auth/github')
def auth_github():
state = secrets.token_urlsafe(16)
session['oauth_state'] = state
params = {
'client_id': GITHUB_CLIENT_ID,
'redirect_uri': 'https://myapp.com/auth/callback',
'scope': 'read:user user:email',
'state': state,
'response_type': 'code'
}
return redirect(f"https://github.com/login/oauth/authorize?{urlencode(params)}")
# Step 2: Handle callback, exchange code for token
@app.route('/auth/callback')
def auth_callback():
# Verify state to prevent CSRF
if request.args.get('state') != session.get('oauth_state'):
return 'State mismatch', 400
code = request.args.get('code')
# Exchange code for access token
response = requests.post('https://github.com/login/oauth/access_token', {
'client_id': GITHUB_CLIENT_ID,
'client_secret': GITHUB_CLIENT_SECRET,
'code': code,
'redirect_uri': 'https://myapp.com/auth/callback'
}, headers={'Accept': 'application/json'})
tokens = response.json()
access_token = tokens['access_token']
# Use token to get user info
user_info = requests.get(
'https://api.github.com/user',
headers={'Authorization': f'Bearer {access_token}'}
).json()
# Create or find user in your database
user = db.upsert_user(github_id=user_info['id'], email=user_info['email'])
session['user_id'] = user.id
return redirect('/dashboard')
2. Authorization Code + PKCE β required for SPAs and mobile apps (no client secret):
// Generate PKCE code verifier and challenge
function generatePKCE() {
const verifier = crypto.getRandomValues(new Uint8Array(32));
const verifierBase64 = btoa(String.fromCharCode(...verifier))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// SHA-256 hash of verifier
const encoder = new TextEncoder();
const data = encoder.encode(verifierBase64);
return crypto.subtle.digest('SHA-256', data).then(hash => {
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return { verifier: verifierBase64, challenge };
});
}
// Include in authorization URL
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params
// Include verifier when exchanging code for token
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
code_verifier: sessionStorage.getItem('pkce_verifier'),
// ... other params
})
});
3. Client Credentials β for machine-to-machine (no user involved):
curl -X POST https://auth.example.com/token \
-d "grant_type=client_credentials" \
-d "client_id=SERVICE_ID" \
-d "client_secret=SERVICE_SECRET" \
-d "scope=read:data"
4. Implicit Flow β deprecated. Donβt use it. It sends tokens directly in redirect URLs, exposing them to browser history and referrer headers. Use Authorization Code + PKCE instead.
OAuth 2.0 vs OIDC
OAuth 2.0 gives you an access token. It tells the resource server what the app can do, but not who the user is. OpenID Connect (OIDC) is a layer on top of OAuth 2.0 that adds an ID token (a JWT) containing user identity information.
OAuth 2.0 alone: access_token (opaque or JWT, for API access)
OIDC on top: access_token + id_token (who the user is) + refresh_token
If youβre doing βSign in with Google/GitHub/Apple,β youβre using OIDC, not just OAuth 2.0.
Comparison: When to Use Each
| Criterion | Sessions | JWT | OAuth 2.0 |
|---|---|---|---|
| Architecture | Monolith, server-rendered | Microservices, APIs | Third-party auth, SSO |
| Scalability | Needs shared state (Redis) | Stateless, scales easily | Depends on provider |
| Revocation | Immediate | Hard (blocklist or short TTL) | Revoke at provider |
| Storage overhead | Server-side storage | Client-side (token size) | Provider manages |
| Complexity | Low | Medium | High |
| Cross-domain | Difficult (CORS, SameSite) | Easy (Authorization header) | Designed for it |
| User identity | Direct | Embedded in token | Via ID token (OIDC) |
Use Sessions When:
- Building a traditional server-rendered web app
- You need immediate session invalidation (force logout, account ban)
- Your app is a single service (not microservices)
- Youβre using a framework like Django, Rails, or Laravel that handles sessions well
Use JWT When:
- Building a REST API consumed by mobile apps or SPAs
- Working in a microservices architecture where multiple services need to verify identity without hitting a central auth DB
- Cross-origin requests (mobile apps, third-party API clients)
- You can tolerate the complexity of refresh token rotation
Use OAuth 2.0 When:
- Allowing users to log in with existing accounts (Google, GitHub, etc.)
- Building an API that third-party applications will access on behalf of users
- Building a platform with SSO across multiple services
- You donβt want to manage passwords at all
Security Checklist
Sessions
-
HttpOnlyflag on session cookies -
Secureflag (HTTPS only) -
SameSite=LaxorStrict(CSRF protection) - Regenerate session ID after login (prevent fixation)
- Set appropriate session timeout
- Use Redis or a database for session storage in production
JWT
- Specify algorithm explicitly (
algorithms: ['RS256']) - Use RS256 for multi-service architectures
- Short access token expiry (15 min recommended)
- Rotate refresh tokens on use
- Store refresh tokens in HttpOnly cookies, not localStorage
- Never store sensitive data in payload (itβs readable)
- Validate
iss,aud,exp,nbfclaims
OAuth 2.0
- Always validate
stateparameter (CSRF on redirect) - Use PKCE for public clients (SPAs, mobile)
- Use Authorization Code flow, never Implicit
- Validate
redirect_uristrictly - Request minimal scopes
- Verify ID token signature and claims if using OIDC
Production Considerations
Token storage on the frontend. The debate: localStorage vs HttpOnly cookies.
localStorage: accessible to JavaScript, vulnerable to XSSHttpOnly cookies: not accessible to JS (XSS protection), but need CSRF protection
The safest approach for SPAs: store access tokens in memory (JavaScript variable), store refresh tokens in HttpOnly cookies. Tokens in memory are lost on page refresh, requiring a silent refresh via the refresh token.
Database hits on every request. With sessions, you always hit storage. With JWT, you donβt β but you lose the ability to check if the user is still valid (were they banned? did they change their password?). A compromise: use a short JWT expiry and check a fast cache (Redis) on refresh token exchange.
Key rotation. For JWT with RS256, you need to rotate signing keys periodically without invalidating all existing tokens. The standard approach: JWKS (JSON Web Key Sets) endpoint that publishes multiple public keys. Tokens include a kid (key ID) header, and verifiers look up the right key from the JWKS endpoint.
# JWKS endpoint response
GET https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "key-2026-01",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
Practical Decision Tree
Are you building a third-party integration or "login with X"?
ββ YES β OAuth 2.0 + OIDC (Authorization Code + PKCE for SPAs)
Is your app a monolith with server-rendered pages?
ββ YES β Sessions (simple, battle-tested, easy to invalidate)
Do you need microservices or cross-origin API access?
ββ YES β JWT (RS256, short expiry, refresh tokens)
ββ Need immediate revocation? β Add a token blocklist or use very short expiry
Mobile app?
ββ YES β OAuth 2.0 + PKCE (if using third-party auth) or JWT (if your own auth)
Summary
Sessions, JWT, and OAuth 2.0 are not competing alternatives β they solve different problems and often complement each other.
- Sessions are the right default for traditional web apps. Theyβre simple, immediately revocable, and well-supported by every web framework.
- JWT is ideal for stateless API authentication in distributed systems. The complexity is real β get refresh token rotation and revocation right, or youβll have security holes.
- OAuth 2.0 is for delegated authorization and federated identity. βLogin with Googleβ is OAuth 2.0 + OIDC. Third-party API access is OAuth 2.0.
In practice, many production systems use all three: OAuth 2.0 for social login, sessions or JWT for managing the resulting authenticated state within the app, and service-to-service JWT for microservice communication.
Looking for tools to inspect and debug your JWTs? Try the JWT decoder and base64 decoder in our free developer toolbox.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.