Authentication: JWT, OAuth, and Sessions Explained

Authentication: JWT, OAuth, and Sessions Explained

Three Systems Walk Into a Login Form

Picture it. Wednesday afternoon, 3:47 PM IST, and a user named Kavita just clicked “Log In” on your app. Behind the scenes, something mundane is about to happen — her browser will send a POST request with her email and password. What happens next, though? That depends entirely on which camp your backend belongs to.

One server wants to create a little file about Kavita, stash it in Redis, and hand her a cookie that says “come back with this next time.” Another server wants to stamp her identity onto a cryptographic token, sign it, and say “carry this around — I won’t remember you otherwise.” And a third server doesn’t even want her password at all. It’d rather redirect her to Google and let them vouch for her.

Sessions. JWTs. OAuth. I’ve used all three in production, sometimes in the same app, and I’ve got strong opinions about when each one shines and when each one quietly ruins your weekend. Let me walk you through them the way I actually learned them — by building things that broke.

Sessions: The Old Guard That Still Works

My first real Node.js project was a campus grievance portal during my final year of college, back in 2018. We needed students to log in, submit complaints, and track responses. Nobody on the team had heard of JWTs. Sessions were all we knew, and honestly? For what we were building, sessions were perfect.

Here’s the mental model. When Kavita logs in, the server creates a session — basically a small record with an ID. It stores that record somewhere (memory, a database, Redis) and sends the ID back to Kavita’s browser as a cookie. Every time she makes a request after that, her browser automatically includes the cookie. Server looks up the ID, finds the session record, and goes “ah, that’s Kavita, user #47.”

Nothing clever. Nothing cryptographic on the client side. Just a random ID and a lookup table. Sounds almost too simple, doesn’t it?

Let me show you what it looks like wired up in Express:

npm install express express-session connect-redis redis bcryptjs
// Session-based auth setup
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcryptjs');

const app = express();
app.use(express.json());

// Redis client for session storage
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();

// Session middleware
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET || 'change-this-secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    httpOnly: true,    // Prevents JavaScript access
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
    sameSite: 'lax',   // CSRF protection
  },
}));

// In-memory user store (use a database in production)
const users = [];

// Registration
app.post('/auth/register', async (req, res) => {
  const { email, password, name } = req.body;
  const hashedPassword = await bcrypt.hash(password, 12);
  const user = { id: users.length + 1, email, name, password: hashedPassword };
  users.push(user);
  res.status(201).json({ message: 'User created' });
});

// Login
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Store user info in session
  req.session.userId = user.id;
  req.session.email = user.email;

  res.json({ message: 'Logged in', user: { id: user.id, name: user.name } });
});

// Logout: destroy the session
app.post('/auth/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});

// Session auth middleware
function requireSession(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

// Protected route
app.get('/profile', requireSession, (req, res) => {
  const user = users.find(u => u.id === req.session.userId);
  res.json({ id: user.id, name: user.name, email: user.email });
});

See how httpOnly: true sits on that cookie config? Hugely important. Without it, any rogue JavaScript on your page (think XSS attacks) can steal session IDs right out of document.cookie. And sameSite: 'lax' adds a layer of CSRF protection that didn’t even exist when I built that college portal. We had to bolt on CSRF tokens manually back then. Browsers have gotten smarter since.

Now, the elephant in the room with sessions: scalability. If you’re running one server, sessions in memory work fine. But deploy three instances behind a load balancer? Kavita might log in on Server A, get her session stored in Server A’s memory, and then her next request hits Server B. Server B has no idea who she is. Kicked out. Confused. Filing a support ticket.

That’s exactly why the code above uses Redis. Every server instance reads from the same Redis store. Problem solved — though you’ve now added a dependency. Redis goes down, everybody’s logged out.

But here’s where sessions really earn their keep: instant revocation. Kavita’s account got compromised? Delete her session from Redis. Done. She’s logged out everywhere, immediately, no waiting for anything to expire. Try doing that with a JWT. (Spoiler: it’s painful.)

I’ll be blunt. For a monolith serving a web app on a single domain — which, let’s be honest, describes probably 70% of projects built in India’s startup ecosystem — sessions are still the best choice. They’re harder to mess up, they revoke instantly, and cookies handle transport automatically. You don’t need to write a single line of client-side token management code.

JWTs: Freedom Comes With Baggage

Around 2020, I got pulled into a project that needed a mobile app and a web app talking to the same backend. Suddenly sessions felt awkward. Mobile apps don’t love cookies. Cross-domain requests get messy. A colleague said “just use JWTs” like it was obvious, and for that project, maybe it was.

A JWT is a string broken into three parts, separated by dots. Header, payload, signature. All Base64-encoded, none of it encrypted (huge misconception). Anyone can decode a JWT and read what’s inside. What they can’t do is tamper with it, because changing even one character invalidates the signature.

When Kavita logs in, instead of creating a session, the server builds a JWT containing her user ID, email, maybe her role, signs it with a secret key, and sends it back. Her browser (or mobile app) stores it and sends it in the Authorization header on every request. Server receives it, checks the signature, reads the payload. No database lookup. No Redis. Stateless.

Sounds beautiful, right? Let me show you the code before I tell you where it hurts.

npm install jsonwebtoken
// JWT auth implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'access-secret-change-me';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret-change-me';

// Store refresh tokens (use Redis in production)
const refreshTokens = new Set();

function generateTokens(user) {
  const accessToken = jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { id: user.id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  refreshTokens.add(refreshToken);
  return { accessToken, refreshToken };
}

// Login endpoint
app.post('/auth/jwt/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const tokens = generateTokens(user);
  res.json(tokens);
});

// Refresh endpoint
app.post('/auth/jwt/refresh', (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken || !refreshTokens.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }

  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    const user = users.find(u => u.id === decoded.id);

    // Rotate: invalidate old refresh token, issue new pair
    refreshTokens.delete(refreshToken);
    const tokens = generateTokens(user);
    res.json(tokens);
  } catch (err) {
    refreshTokens.delete(refreshToken);
    res.status(403).json({ error: 'Token expired or invalid' });
  }
});

// JWT auth middleware
function requireJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' });
  }

  try {
    const token = authHeader.split(' ')[1];
    req.user = jwt.verify(token, ACCESS_SECRET);
    next();
  } catch (err) {
    const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
    res.status(401).json({ error: message });
  }
}

// Protected route
app.get('/api/data', requireJWT, (req, res) => {
  res.json({ message: `Hello ${req.user.email}`, userId: req.user.id });
});

Notice the two-token pattern. An access token that lives for just 15 minutes, and a refresh token that lasts 7 days. Why? Because JWTs have a fundamental flaw: you can’t revoke them.

Once an access token is out there, it’s valid until it expires. Server doesn’t track it. Can’t delete it. If someone steals it, they’ve got 15 minutes of free access. Keeping that window short limits the damage. When the access token expires, the client hits the refresh endpoint, trades the old refresh token for a fresh pair, and the old refresh token gets deleted from the Set. Token rotation. Prevents a stolen refresh token from being useful forever.

And this is where I get opinionated. A lot of tutorials store JWTs in localStorage. Don’t. localStorage is accessible to every script on your page. One XSS vulnerability and your tokens are gone — stolen, exfiltrated, game over. If you’re building a browser-based app, store the refresh token in an httpOnly cookie and keep the access token in memory (a JavaScript variable that vanishes on page reload). Yeah, it means re-fetching the access token on every page load. That’s fine. Security isn’t free.

I’ve seen teams adopt JWTs for simple CRUD apps that run on a single domain, no mobile client, no microservices. It’s overkill. You’re solving problems you don’t have while introducing problems you didn’t expect. Token storage, token refresh, token rotation, blacklisting on logout — all this machinery that sessions handle with one Redis call. My rule of thumb: if you aren’t crossing domain boundaries or serving non-browser clients, you probably don’t need JWTs.

A Quick Detour: Where People Get Burned

Between 2021 and 2023, I reviewed a bunch of codebases at a consultancy in Bengaluru. Authentication bugs came up in nearly every audit. Let me share the patterns I kept seeing.

Mistake one: storing the JWT secret in the codebase. Literally hardcoded. Pushed to GitHub. Public repo. I found it because someone had committed a file called config.js with const SECRET = 'myapp2022' sitting right there. Anyone could forge valid tokens. Shift secrets to environment variables. Always.

Mistake two: not validating the alg header. Some JWT libraries accept "alg": "none" — which means no signature required. Attackers figured this out years ago. Modern versions of jsonwebtoken for Node.js reject this by default, but older versions didn’t. Keep your dependencies updated.

Mistake three: massive JWT payloads. One team had embedded entire user permission trees — 40+ fields — into the token. Every API request carried kilobytes of auth data. JWTs go in headers. Headers have size limits. Performance suffered, and they couldn’t figure out why. Keep tokens lean: user ID, role, email. Nothing more.

Mistake four: session fixation. A team using sessions forgot to regenerate the session ID after login. Meaning an attacker who set a cookie before the user logged in could hijack the authenticated session. Express-session handles this if you’re careful, but it’s easy to overlook when you’re racing toward a launch.

OAuth: Let Someone Else Handle the Hard Part

Okay, back to Kavita. What if she doesn’t want to create yet another password? She’s already got a Google account, a GitHub account, maybe a Microsoft account from work. She’d rather just click a button and be done.

That’s OAuth. Or more precisely, OAuth 2.0, which is an authorization framework — not strictly authentication, though everyone uses it for authentication anyway. (OpenID Connect, built on top of OAuth 2.0, is the actual authentication layer, but in practice the distinction rarely matters for what we’re building.)

Here’s how the dance goes. Kavita clicks “Sign in with Google.” Your server redirects her to Google’s consent screen. She says “yes, let this app see my profile and email.” Google redirects her back to your server with an authorization code. Your server exchanges that code for an access token directly with Google’s servers. Then it uses that token to fetch Kavita’s profile. Now you’ve got her name and email, verified by Google, without ever touching her password.

Your app doesn’t store passwords. You don’t handle password resets. You don’t worry about credential stuffing attacks. Google does all of that. You just trust the profile they hand you.

Let me show you how Passport.js makes this work:

npm install passport passport-google-oauth20
// OAuth 2.0 with Google
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/auth/google/callback',
  },
  async (accessToken, refreshToken, profile, done) => {
    // Find or create user in your database
    let user = users.find(u => u.googleId === profile.id);

    if (!user) {
      user = {
        id: users.length + 1,
        googleId: profile.id,
        name: profile.displayName,
        email: profile.emails[0].value,
        avatar: profile.photos[0]?.value,
      };
      users.push(user);
    }

    return done(null, user);
  }
));

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
  const user = users.find(u => u.id === id);
  done(null, user);
});

app.use(passport.initialize());
app.use(passport.session());

// Initiate Google OAuth flow
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

// Google callback handler
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => {
    // Successful authentication
    res.redirect('/dashboard');
  }
);

// Check if authenticated via OAuth
function requireOAuth(req, res, next) {
  if (!req.isAuthenticated()) {
    return res.status(401).json({ error: 'Please sign in with Google' });
  }
  next();
}

app.get('/dashboard', requireOAuth, (req, res) => {
  res.json({
    name: req.user.name,
    email: req.user.email,
    avatar: req.user.avatar,
  });
});

Look at serializeUser and deserializeUser. After OAuth finishes, Passport stores the user ID in a session. So you’re back to sessions again. OAuth handled the “who are you?” question. Sessions handle the “I remember you” part. See how they layer?

One thing I need to flag: OAuth adds external dependencies. If Google’s auth servers go down (rare, but it’s happened — February 2024 had a brief outage that affected a few of my client apps), your users can’t log in. For apps where uptime is non-negotiable, you might want to offer email/password login as a fallback. Or at least have a plan.

There’s also a subtle security concern that trips up newer developers. The authorization code flow (what we’re using above) is secure because the code-to-token exchange happens server-to-server. Your client secret never touches the browser. But I’ve seen teams accidentally implement the implicit flow in SPAs — where the token comes directly to the browser through the URL. Don’t. It’s been deprecated for good reason. If you’re building a SPA, use the authorization code flow with PKCE. Passport handles the server-side part; for the browser redirect, keep it clean.

Combining Them: What Production Actually Looks Like

Here’s something tutorials rarely admit: most production apps use more than one of these.

My current setup for a SaaS product I’ve been working on since late 2024 looks like this. Users sign up with Google OAuth (or GitHub OAuth for developer-facing features). Once authenticated, the server issues a JWT pair — short-lived access token for API calls, refresh token stored in an httpOnly cookie. Why not just sessions? Because the API also serves a React Native mobile app, and cookies across platforms are a headache I’d rather avoid.

But — and here’s the part most people skip — I also maintain a token blacklist in Redis. When a user logs out, their refresh token goes into the blacklist. When a user changes their password, all their refresh tokens get blacklisted. Yes, this adds state. Yes, this partially defeats the “stateless” selling point of JWTs. But security matters more than architectural purity. A stateless system that can’t revoke access isn’t safe; it’s negligent.

The flow looks roughly like this:

  1. User clicks “Sign in with Google”
  2. OAuth dance happens, server gets verified profile
  3. Server creates or finds user record in the database
  4. Server generates JWT pair (access + refresh)
  5. Access token sent in response body, refresh token set as httpOnly cookie
  6. Client stores access token in memory
  7. Every API call includes access token in Authorization header
  8. When access token expires, client calls /refresh endpoint
  9. Server checks refresh token against blacklist, then issues new pair
  10. On logout, refresh token goes into Redis blacklist

Complicated? Compared to plain sessions, absolutely. Worth it when you’re serving web, mobile, and potentially third-party integrations from the same API? Without question.

So When Do You Actually Use Each One?

I’m going to be direct because I’ve watched too many developers agonize over this decision when the answer was sitting right in front of them.

Use sessions when: your frontend and backend share the same domain. You need instant revocation (banning users, forced logouts). Your architecture is a monolith or a small cluster. You aren’t serving mobile clients. This covers Django apps, Rails apps, traditional Express apps with server-rendered views, and plenty of Next.js setups where the API routes live in the same deployment. Sessions are the simplest to implement correctly and the hardest to misuse. Start here unless you have a concrete reason not to.

Use JWTs when: you’re building an API that non-browser clients will consume. Mobile apps. Third-party integrations. Microservices that need to verify identity without calling a central session store on every request. You want to embed claims — roles, permissions, tenant IDs — directly into the token so each service can make authorization decisions independently. Always pair short-lived access tokens with refresh token rotation. Always. I’ve never seen a system that skipped this and didn’t regret it within a year.

Use OAuth when: you want users to sign in with existing accounts (Google, GitHub, Microsoft). You’d rather not manage passwords at all. You’re building a platform where third-party apps need delegated access to user data. OAuth handles the “prove who you are” step, and then you still need sessions or JWTs for everything after. It doesn’t replace them; it layers on top.

And a combination — OAuth for login, JWTs for API access, Redis for revocation — is probably what you’ll end up building if your app gets serious traction. Don’t fight it. Plan for it.

Security Isn’t a Method. It’s an Execution.

Before I wrap up, something needs saying. Picking the “right” auth method doesn’t make your app secure. Implementing the chosen method poorly does make it insecure. I’ve seen bulletproof JWT architectures undermined by a hardcoded secret. I’ve seen sessions deployed without httpOnly cookies, wide open to XSS. I’ve seen OAuth implementations that accepted tokens without verifying them server-side.

A few non-negotiables, regardless of which path you take:

  • HTTPS everywhere. In production, there’s no excuse. Let’s Encrypt is free. Cookies without the Secure flag are visible to network attackers. Tokens in plain HTTP are tokens waiting to be stolen.
  • Hash passwords with bcrypt or Argon2. Twelve rounds minimum for bcrypt. If you’re storing plaintext passwords in 2026, I honestly don’t know what to tell you.
  • Rotate secrets regularly. JWT secrets, session secrets, OAuth client secrets. Treat them like passwords — they expire, they get replaced.
  • Rate-limit login endpoints. Five failed attempts? Slow them down. Twenty? Lock the account temporarily. Without rate limiting, brute force attacks are trivially easy.
  • Log authentication events. Successful logins, failed logins, token refreshes, logouts. When (not if) something goes wrong, your logs are the first thing you’ll reach for.

Where I’ve Landed, Personally

After seven years of building web apps, five of them professionally, here’s my honest stance.

Sessions are underrated. They fell out of fashion when SPAs and mobile apps took over the conversation, and suddenly everyone wanted stateless everything. Stateless is elegant in theory. In practice, you end up bolting state back on (blacklists, refresh token storage, token versioning) and pretending you didn’t. If your app is a monolith serving a web frontend — and there’s no shame in that — sessions with Redis will carry you further than you’d think. I’ve seen them handle tens of thousands of concurrent users without breaking a sweat.

JWTs earn their place in specific contexts: cross-domain APIs, mobile clients, microservice architectures where a central session store becomes a bottleneck or a single point of failure. When you need them, you really need them. When you don’t, they add complexity that shows up as bugs at 2 AM. I think a lot of developers reach for JWTs because they seem modern, not because the architecture demands them. Fight that instinct.

OAuth is, frankly, a gift. Letting Google or GitHub verify identity means I’m not responsible for password storage, password resets, or breach notifications when (let’s be real, not if) a credential database gets compromised. For consumer-facing apps, I default to OAuth with email/password as a fallback. For internal tools, it depends — sometimes LDAP or SSO makes more sense.

If I were starting a new side project tomorrow — say, a tool for tracking competitive programming scores, something a friend has been bugging me about — I’d wire up Google OAuth, stick a session in Redis, and move on. No JWTs. No refresh tokens. Probably 40 lines of auth code total. Ship it, add complexity only when the product demands it. Authentication should protect your users, not become your project.

Kavita, by the way? She just wants to log in and use the app. She doesn’t care which system proved her identity. Make it fast, make it safe, and get out of her way.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.