Gigson Expert

/

March 10, 2026

Understanding Authentication: JWT, OAuth, and Session-Based Auth

Understand modern authentication methods including JWT tokens, OAuth flows, and session-based auth. Compare security trade-offs and implementation use cases.

Blog Image

Nwaokocha Michael

A Web Developer with experience building scalable web and mobile applications. He works with React, TypeScript, Golang, and Node.js, and enjoys writing about the patterns that make software easier to understand. When not debugging distributed systems or writing articles, you can find him reading sci-fi novels or hiking.

Article by Gigson Expert

Authentication is one of those things that seems simple until you actually have to implement it. Then suddenly you're drowning in terms like JWT, OAuth flows, refresh tokens, PKCE, and session stores, wondering why something as basic as "is this person who they say they are?" needs to be so complicated.

The thing is, it's complicated because we've been solving this problem for a very long time, and each solution came with tradeoffs that led to new solutions. Understanding authentication isn't really about memorising which method is "best." It's about understanding what each approach is trying to solve and when those solutions make sense.

So let's start at the beginning.

A Brief History: Locks, Keys, and Trust

Authentication isn't new. The concept is ancient. Before we had computers, we had locks and keys.

Authentication is proving you're allowed in; it is like showing you have the key to a locked door. It's all about proving you possess something only authorised people should have.

Back in the day, though, physical keys could be lost or copied, and letting someone in just for a moment was a pain. Combination locks solved the key problem with a secret code (something you know), which is harder to copy but easier to share or forget. And things like safe deposit boxes brought in dual authentication, you needed your key and the bank's key.

Fast forward to the digital world, and we're dealing with the same stuff: how to confirm who someone is, let them in, and manage what they can do. Our modern digital authentication methods are just updated versions of those age-old physical solutions: keys, codes, and two-person systems.

Why We Need Authentication (And Why It's Gotten More Complex)

Back in the day, website sign-in was easy: you typed your password, and the site checked its database. Now, though, apps are super complex, talking to lots of other services, and people usually just want to sign in with stuff like their Google or GitHub account. Plus, many apps are single-page apps (SPAs) or mobile, so users expect seamless, secure logins across all their devices, and they want fine-grained control over when they log out. With all the hacks and session hijacks happening, security is way more important than ever.

To handle all this complexity, we actually use a few different ways to manage authentication:

  • Sessions: The old reliable choice. Best for traditional, server-driven websites where the server keeps track of everything.
  • JWT (JSON Web Tokens): Perfect for modern SPAs and stateless APIs. This lets the client prove who they are without the server needing to remember every single active session.
  • OAuth: Great for letting users sign in with a third-party service (like Google) or for giving an app limited access to user data without ever sharing their password.

Each of these tools has its own sweet spot; no single one is the "best" for everything.

JWT: The Safe Deposit Box Approach

Think back to that safe deposit box at a bank. You have your key, the bank has theirs, and you need both to open it. JWT (JSON Web Tokens) works on a similar principle, except instead of physical keys, we're using cryptographic signatures.

Here's how it works:

When you log in, the server creates a JWT, basically a signed package of information about you. This package includes things like your user ID, when the token was created, when it expires, and any other claims you want to include. The server signs this package with a secret key that only it knows.

const jwt = require('jsonwebtoken');

// User just logged in successfully
const token = jwt.sign(
  { 
    userId: user.id,
    email: user.email,
    role: user.role 
  },
  process.env.JWT_SECRET,
  { expiresIn: '1h' }
);

// Send token to client
res.json({ token });

The client gets this token and stores it (usually in localStorage or a cookie). From now on, whenever the client makes a request, it sends this token along:

// Client-side code
const response = await fetch('/api/protected-route', {
  headers: {
    'Authorisation': `Bearer ${token}`
  }
});

When the server receives a request with a JWT, it verifies the signature. If the signature is valid, the server knows the token wasn't tampered with, and it can trust the information inside:

// Server middleware
function authenticateToken(req, res, next) {
  const token = req.headers['authorization']?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ error: 'Invalid token' });
  }
}

// Protected route
app.get('/api/profile', authenticateToken, (req, res) => {
  // req.user contains the decoded token data
  res.json({ userId: req.user.userId, email: req.user.email });
});

This is like the safe deposit box because the token contains all the information the server needs, sealed with a signature that proves it came from the server. The client can't open the box or modify what's inside without breaking the seal.

The beautiful thing about JWTs is that they're stateless. The server doesn't need to remember who's logged in. It doesn't need a session store or database lookup. The token itself contains everything needed. This makes JWTs perfect for APIs that need to scale horizontally; any server can verify any token.

But JWTs have downsides. Once you hand out a token, you can't take it back until it expires. If a user logs out, their token is still valid until the expiration time hits. If you need to immediately revoke access, you're out of luck (or you need to maintain a blacklist, which defeats the stateless benefit).

Also, JWTs can get large if you stuff too much data into them, and they're sent with every request, so size matters.

OAuth: The Club Bouncer Approach

OAuth is different. It's not really about authentication at all; it's about authorisation. But people use it for authentication all the time, so let's talk about both.

Think of OAuth like a bouncer at an exclusive club. You want to get into the club (your app), but instead of creating a new membership with the club directly, the club says, If you're already a VIP member at that other famous club (Google, GitHub, Facebook), we'll let you in."

You go to the other club (Google), show them your ID (log in), and they give you a special wristband (access token) that says "this person is legit." You take that wristband back to the first club, and the bouncer (your app) checks with the other club to verify the wristband is real. If it checks out, you're in.

Here's what it looks like in practice. Let's say you want users to sign in with Google:

// Step 1: Redirect user to Google's login
app.get('/auth/google', (req, res) => {
  const googleAuthUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID,
    redirect_uri: 'http://localhost:3000/auth/google/callback',
    response_type: 'code',
    scope: 'openid email profile'
  });
  
  res.redirect(`{googleAuthUrl}?{params}`);
});

The user logs into Google (or they're already logged in), and Google asks, "Do you want to let this app access your basic profile?" If the user says yes, Google redirects back to your app with a special code:

// Step 2: Exchange the code for tokens
app.get('/auth/google/callback', async (req, res) => {
  const { code } = req.query;
  
  // Exchange code for access token
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: 'http://localhost:3000/auth/google/callback',
      grant_type: 'authorization_code'
    })
  });
  
  const tokens = await tokenResponse.json();
  
  // Use the access token to get user info
  const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` }
  });
  
  const userData = await userResponse.json();
  
  // Now you know who the user is!
  // Create your own session or JWT for them
  req. Session.userId = userData.id;
  req.session.email = userData.email;
  
  res.redirect('/dashboard');
});

The bouncer analogy holds up because OAuth is fundamentally about trust delegation. Your app trusts Google to verify who users are. Google trusts its own authentication. Users trust Google with their credentials instead of trusting your app.

OAuth also handles permissions elegantly. Remember those "scopes" in the code above? That's like telling the bouncer exactly what areas of the club you're allowed into. Maybe you can access the bar but not the VIP lounge. In OAuth terms, maybe you can read someone's email, but not send emails on their behalf.

The complexity with OAuth is that it's a protocol, not a specific implementation. There are different "flows" depending on whether you're a web app, mobile app, or server-to-server application. There are concepts like refresh tokens, PKCE, and state parameters that handle security edge cases. It's powerful but not simple.

Session-Based Auth: The Coat Check Approach

Session-based authentication is the traditional approach, and it's like a coat check at a restaurant.

When you arrive at a fancy restaurant and check your coat, they give you a ticket, just a number. They keep your coat behind the counter with that number attached. When you want your coat back, you show the ticket, they look up which coat belongs to that number, and they give it back.

Session-based auth works the same way. When you log in, the server creates a session, a record in a database or memory store, that says "user 123 is logged in." The server generates a random session ID and sends it to your browser as a cookie:

const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { 
    secure: true, // use HTTPS
    httpOnly: true, // can't be accessed by JavaScript
    maxAge: 1000 * 60 * 60 * 24 // 24 hours
  }
}));

// Login route
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await db.findUserByEmail(email);
  const isValid = await bcrypt.compare(password, user.passwordHash);
  
  if (!isValid) {
    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 successfully' });
});

From now on, every request from that browser automatically includes the session cookie. The server reads the session ID, looks up the session data, and knows who you are:

// Middleware to check if user is logged in
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  next();
}

// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await db.findUserById(req.session.userId);
  res.json({ email: user.email, name: user.name });
});

// Logout
app.post('/logout', (req, res) => {
  req.session.destroy();
  res.json({ message: 'Logged out successfully' });
});

The coat check analogy works because the client (your browser) only holds a meaningless ticket (session ID). All the actual information, who you are, what you're allowed to do, stays with the server. The ticket is just a reference.

This has advantages. It's easy to revoke access, just destroy the session. Want to log someone out immediately? Delete their session. Want to see all logged-in users? Query the session store. Need to store temporary data during a user's visit? Put it in the session.

But sessions have downsides too. The server needs to maintain state. That means a database or Redis, or some other session store. If your app scales to multiple servers, they all need access to the same session store. Sessions don't work well for pure APIs that need to be stateless.

Also, sessions are vulnerable to CSRF attacks because the browser automatically sends cookies with every request. You need additional protections like CSRF tokens.

Access a Global pool of Talented and Experienced Developers

Hire skilled professionals to build innovative products, implement agile practices, and use open-source solutions

Start Hiring

Which One Should You Use?

Choosing the best way to handle user logins and authentication really depends on your project.

Sessions are the go-to if you're building a traditional, server-rendered web app—they're the simplest option.

JWTs (JSON Web Tokens) are perfect for modern applications, like SPAs (Single Page Applications) or mobile apps talking to a stateless API. They help you scale horizontally without a fuss.

You'll need OAuth if you want users to sign in with third-party services (like Google or GitHub) or if you need delegated access, even though it adds a bit of complexity.

In reality, most professional apps mix and match: maybe they use OAuth for the initial login, and then switch to Sessions or JWTs for every request after that. The key takeaway is knowing the basics: a JWT is a secure, signed info-package, OAuth is all about delegating access, and sessions are just server-side status updates tracked by cookies. Nail that, and you'll make the right choice.

Session End

Authentication is complex due to the genuinely hard problems it solves: proving identity over untrusted networks, managing cross-service access, balancing security and usability, and adapting to evolving threats.

Implementation involves crucial tradeoffs: deciding where to store trust (client/server), its duration (stateless tokens/revocable sessions), and the identity verification method (internal or third-party). There are no perfect answers; for example, JWTs are stateless but hard to revoke, while sessions are revocable but require server state. The optimal approach depends on your specific situation and requirements.

Frequently Asked Questions

Q: What's the difference between authentication and authorisation?

Authentication is proving who you are. Authorisation determines what you're allowed to do. You authenticate by logging in with a password. You're authorised to edit your profile but not someone else's. OAuth is technically an authorisation protocol, but OpenID Connect (built on top of OAuth) adds authentication.

Q: How do refresh tokens work with JWT?

The pattern is: give users a short-lived access token (15 minutes) and a long-lived refresh token (7 days). When the access token expires, use the refresh token to get a new access token without making the user log in again. The refresh token should be stored securely (httpOnly cookie) and can be revoked. This gives you JWT's stateless benefits with some revocation ability.

Q: Can't attackers just steal session cookies or JWTs and impersonate users?

Yes, this is called session hijacking or token theft. Mitigations include: HTTPS always (prevents man-in-the-middle attacks), httpOnly cookies (prevents JavaScript access), short expiration times, refresh token rotation, detecting suspicious login patterns, and allowing users to see active sessions and revoke them. No single solution is perfect; security is in layers.

Q: Should I build my own authentication or use a service like Auth0?

For learning? Build your own. For production, especially if you're handling sensitive data or need OAuth? Consider a service. Authentication is one of those things that seems simple until you hit edge cases: password reset flows, email verification, rate limiting, account lockouts, two-factor auth, and social logins. Auth services have solved these problems. Whether the cost is worth it depends on your situation.

Q: How do I handle authentication in microservices?

This is complex. Common patterns include: API gateway handles authentication and passes user info to services, each service validates JWT independently, or uses a shared session store. The stateless nature of JWT makes it popular here because services can validate tokens without coordinating. But if you need immediate revocation, you might need a token blacklist or shorter expiration times with refresh tokens.

Subscribe to our newsletter

The latest in talent hiring. In Your Inbox.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Hiring Insights. Delivered.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Request a call back

Lets connect you to qualified tech talents that deliver on your business objectives.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.