Skip to main content
← All Articles Β· Β· 14 min read

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.

authenticationoauth2jwtsessionssecurityweb-developmentapi-security

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:

  1. User submits credentials (username + password)
  2. Server verifies credentials, creates a session record in storage (database, Redis, memory)
  3. Server sends back a session ID in a Set-Cookie header
  4. Browser stores the cookie and sends it automatically on subsequent requests
  5. 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

StorageProsCons
In-memoryFast, simpleLost on restart, single-process only
Database (PostgreSQL)Persistent, queryableSlower, requires DB hit per request
RedisFast, persistent, distributedInfrastructure dependency
Sticky sessions (load balancer)Simple with in-memorySingle 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

CriterionSessionsJWTOAuth 2.0
ArchitectureMonolith, server-renderedMicroservices, APIsThird-party auth, SSO
ScalabilityNeeds shared state (Redis)Stateless, scales easilyDepends on provider
RevocationImmediateHard (blocklist or short TTL)Revoke at provider
Storage overheadServer-side storageClient-side (token size)Provider manages
ComplexityLowMediumHigh
Cross-domainDifficult (CORS, SameSite)Easy (Authorization header)Designed for it
User identityDirectEmbedded in tokenVia 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

  • HttpOnly flag on session cookies
  • Secure flag (HTTPS only)
  • SameSite=Lax or Strict (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, nbf claims

OAuth 2.0

  • Always validate state parameter (CSRF on redirect)
  • Use PKCE for public clients (SPAs, mobile)
  • Use Authorization Code flow, never Implicit
  • Validate redirect_uri strictly
  • 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 XSS
  • HttpOnly 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.

Free Download

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.