DEV Community

Timevolt
Timevolt

Posted on

Authentication Done Right: JWT, Sessions, and OAuth – The Matrix of Secure Logins

The Quest Begins (The "Why")

Honestly, I still remember the first time I tried to add login to a side‑project. I slapped a JWT onto the front‑end, tossed it into localStorage, and called it a day. “Yeah, I’m done,” I thought, feeling like Neo after he finally sees the code. Then I opened the dev tools, saw my token sitting there for anyone to steal, and realized I’d just handed the villain a master key.

That moment kicked off a mini‑odyssey: why do we have three seemingly overlapping ways to authenticate—JWTs, server‑side sessions, and OAuth? When should I use each? What are the real‑world traps that turn a “cool feature” into a security nightmare? I wanted a clear map, not a pile of blog posts that contradict each other. So I dug in, fought a few bugs, and emerged with a set of patterns that actually work in production. Let me share the loot.

The Revelation (The Insight)

Here’s the thing: JWTs, sessions, and OAuth aren’t competitors; they’re tools for different jobs.

  • JWTs are great for stateless APIs where you need to carry claims (like “user_id = 42, role = admin”) across services without hitting a database on every request.
  • Sessions (the classic cookie‑based approach) shine when you want the server to keep tight control—think traditional web apps where you can invalidate a session instantly on logout or password change.
  • OAuth (especially OAuth 2.0 with OpenID Connect) is the delegated auth wizard: let users sign in with Google, GitHub, or your own auth server while you never see their password.

The insight that changed everything for me was realizing that you can combine them. A typical modern stack looks like this:

  1. OAuth (or a custom login endpoint) authenticates the user and returns a short‑lived JWT access token plus a refresh token.
  2. The access token lives in memory (or a short‑lived, HTTP‑only cookie) and is sent with API calls.
  3. The refresh token is stored in an HTTP‑only, SameSite=Strict, Secure cookie, letting the client silently obtain new access tokens without exposing the refresh token to JavaScript.
  4. For server‑rendered pages you might still keep a classic session cookie to hold UI state (like theme or cart) while the API calls rely on the JWT.

That split gives you the best of both worlds: stateless scalability for APIs and strong server‑side control for sensitive actions.

Wielding the Power (Code & Examples)

The Struggle: Naïve JWT in localStorage

// login.js – the “quick and dirty” way (don’t do this!)
async function login(email, password) {
  const res = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });
  const { token } = await res.json();   // <-- JWT
  localStorage.setItem('jwt', token);   // 🚨 vulnerable to XSS
}
Enter fullscreen mode Exit fullscreen mode

Traps:

  • XSS can steal the token instantly.
  • No easy way to invalidate a token (you’re stuck until it expires).
  • Refresh token handling? None.

The Victory: Secure Split‑Token Pattern

1. Login endpoint (Node/Express)

// authController.js
const jwt = require('jsonwebtoken');
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);

async function login(req, res) {
  const { idToken } = req.body; // token from Google Sign‑In
  const ticket = await client.verifyIdToken({
    idToken,
    audience: process.env.GOOGLE_CLIENT_ID,
  });
  const payload = ticket.getPayload();
  const userId = payload.sub; // Google's unique user ID

  // Short‑lived access token (15 min)
  const accessToken = jwt.sign(
    { sub: userId, email: payload.email },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  // Refresh token (long‑lived, stored HTTP‑only)
  const refreshToken = jwt.sign(
    { sub: userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '30d' }
  );

  // Set refresh token in a secure, HTTP‑only cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  });

  // Send access token in the response body (SPA will keep it in memory)
  res.json({ accessToken });
}
Enter fullscreen mode Exit fullscreen mode

2. Using the access token on the frontend

// api.js – fetch wrapper
async function fetchWithAuth(url, options = {}) {
  let accessToken = window.__ACCESS_TOKEN__; // kept in a JS module or closure
  if (!accessToken) {
    // attempt silent refresh
    const res = await fetch('/api/refresh', { credentials: 'include' });
    const data = await res.json();
    accessToken = data.accessToken;
    window.__ACCESS_TOKEN__ = accessToken;
  }

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
    credentials: 'include', // needed for the refresh cookie
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Refresh endpoint

// refreshController.js
async function refresh(req, res) {
  const token = req.cookies.refreshToken;
  if (!token) return res.sendStatus(401);

  try {
    const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
    const newAccess = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken: newAccess });
  } catch (err) {
    res.clearCookie('refreshToken');
    res.sendStatus(401);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The access token lives only in memory (or a short‑lived cookie) → safe from XSS.
  • The refresh token is an HTTP‑only cookie → inaccessible to JavaScript, rotated on each use if you want extra safety.
  • Logging out is just res.clearCookie('refreshToken'); the access token dies naturally in ≤15 min.
  • You can still keep a classic express-session cookie for server‑rendered UI if you need it—no conflict.

Common Pitfalls to Avoid

Trap What happens Fix
Storing JWT in localStorage or sessionStorage XSS steals it → account takeover Keep it in memory or an HTTP‑only cookie with a short expiry
Using the same secret for access and refresh tokens Leak of one compromises both Separate secrets (JWT_ACCESS_SECRET vs JWT_REFRESH_SECRET)
Forgetting SameSite and Secure flags on cookies Cookie sent cross‑site or over HTTP → CSRF / MITM Always set SameSite: 'Strict' (or 'Lax' if you need POSTs) and Secure: true in production
Not validating the JWT signature or aud/iss claims Accepting forged tokens Use a library that does verification (jsonwebtoken.verify) and check claims

Why This New Power Matters

With this pattern you can:

  • Scale horizontally – your stateless API services don’t need sticky sessions; they just verify JWT signatures.
  • Revoke sessions instantly – delete the refresh cookie and the user is logged out everywhere within the access token’s lifetime.
  • Leverage third‑party auth – plug in Google, GitHub, or your own OIDC provider without writing password logic yourself.
  • Sleep better at night – you’ve eliminated the biggest client‑side storage attack surface and added server‑side control where it counts.

Honestly, implementing this felt like finally learning the Force after years of swinging a lightsaber blindly. The code is a little more involved, but the payoff—security, flexibility, and peace of mind—is worth every line.

Your Turn

Take a small project you’ve got lying around (maybe a todo‑app with a basic JWT login) and try adding the refresh‑token HTTP‑only cookie flow. If you hit a snag, drop a question in the comments—I love hearing how others are tackling the auth dragon.

Challenge: After you’ve got the refresh flow working, attempt to issue a new refresh token on each use (token rotation) and invalidate the old one. It’s a neat extra‑hard mode that makes stolen refresh tokens useless after a single use.

May your tokens be ever secure, and may your logs stay clean! 🚀

Top comments (0)