McTaba Labs logo
By Bonaventure Ogeto|

Sessions vs JWT: How Authentication Actually Works

Session-based authentication stores your login state on the server and gives you a cookie with a session ID. JWT (JSON Web Token) authentication stores your login state inside the token itself, which the client sends with every request. Sessions are simpler and easier to revoke. JWTs are stateless and work better across multiple servers. Most modern web apps use one or the other, and the choice depends on your architecture.

The problem: HTTP does not remember you

HTTP is stateless. Every request your browser makes is independent. The server has no idea that the person who just requested /dashboard is the same person who logged in ten seconds ago.

Think about it like this: you walk into a busy bank. Every time you approach the counter, the teller has no memory of you. Even if you were just there a minute ago, they ask "Who are you? What do you need?" every single time.

Authentication solves this problem. After you prove who you are (by entering your password), the server gives you something to carry, a proof of identity, so you do not have to log in again with every click.

There are two main approaches: sessions and JWTs.

Sessions: the server remembers you

With session-based authentication, the server keeps a record of your login.

The flow:

  1. You submit your email and password.
  2. The server checks your credentials against the database.
  3. If correct, the server creates a session: a record stored in memory or a database with a unique session ID.
  4. The server sends back a cookie containing the session ID.
  5. Your browser stores the cookie and sends it automatically with every subsequent request.
  6. The server looks up the session ID, finds your session record, and knows who you are.
// Server-side: creating a session (Express.js example)
import session from 'express-session';

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,    // JavaScript cannot access the cookie
    secure: true,      // Only sent over HTTPS
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));

// Login route
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);

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

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

  res.json({ message: 'Logged in' });
});

// Protected route
app.get('/dashboard', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not logged in' });
  }
  res.json({ message: `Welcome, user ${req.session.userId}` });
});

The session data (your user ID, role, etc.) stays on the server. The cookie only contains the session ID, which is meaningless on its own.

JWT: the token carries your identity

With JWT-based authentication, the server does not store session data. Instead, it creates a signed token that contains your identity, and you carry it with you.

The flow:

  1. You submit your email and password.
  2. The server checks your credentials.
  3. If correct, the server creates a JWT: a token containing your user ID, role, and expiration time, signed with a secret key.
  4. The server sends the token back to you.
  5. Your app stores the token (usually in memory or a cookie) and sends it in the Authorization header with every request.
  6. The server verifies the token's signature and reads your identity from it. No database lookup needed.
// Server-side: creating a JWT
import jwt from 'jsonwebtoken';

// Login route
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);

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

  // Create a token with user data
  const token = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );

  res.json({ token });
});

// Protected route
app.get('/dashboard', (req, res) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1]; // "Bearer "

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    res.json({ message: `Welcome, user ${payload.userId}` });
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

A JWT has three parts separated by dots: header, payload, and signature. The payload is not encrypted, it is just Base64-encoded. Anyone can read it. The signature proves it was not tampered with.

// A JWT looks like this:
// eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.abc123signature
//    [header]            [payload]                              [signature]

// Decoded payload:
// { "userId": 1, "role": "admin", "exp": 1722700800 }

Honest comparison: when to use which

Sessions are better when:

  • You need to revoke access immediately (ban a user, force logout). Just delete the session record.
  • You have a single server or a shared session store (Redis).
  • You want simplicity. Sessions are straightforward and well understood.
  • You are building a traditional server-rendered web app.

JWTs are better when:

  • You have multiple servers or microservices. Each server can verify the token independently without querying a shared session store.
  • You need cross-domain authentication (your API is at api.example.com and your frontend is at app.example.com).
  • You are building a mobile app that calls your API.

The honest tradeoffs of JWT:

  • You cannot revoke a JWT before it expires (without maintaining a blocklist, which defeats the stateless advantage). If a user's account is compromised, you have to wait for the token to expire.
  • Token size matters. JWTs can be large (especially with many claims), and they are sent with every request.
  • Secret rotation is complex. If your JWT signing secret is compromised, every issued token is compromised.
  • Do not store JWTs in localStorage. It is vulnerable to XSS attacks. Use httpOnly cookies or keep the token in memory.

How Supabase handles it (so you do not have to build your own)

Building your own authentication system is educational, but for production apps, use a battle-tested solution. Supabase Auth (which McTaba projects use) combines both approaches:

  • It issues JWTs for API access.
  • It uses refresh tokens stored in httpOnly cookies to issue new JWTs when they expire.
  • It handles session management, token rotation, and revocation for you.
// Supabase handles auth for you
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(url, anonKey);

// Login
const { data, error } = await supabase.auth.signInWithPassword({
  email: 'wanjiku@example.com',
  password: 'securepassword',
});

// The JWT is managed automatically
// Supabase refreshes it before expiry
// You just check if the user is logged in:
const { data: { user } } = await supabase.auth.getUser();

Understanding sessions and JWTs matters because every auth library uses one or both under the hood. When something goes wrong (expired tokens, session mismatches, cookie issues), you need to know what is happening beneath the abstraction.

Frequently Asked Questions

Is JWT more secure than sessions?
Neither is inherently more secure. Both can be implemented securely or insecurely. Sessions are easier to get right because revoking access is simple (delete the session). JWTs require more careful handling (token storage, expiration strategy, secret management). Security depends on your implementation, not the approach you choose.
Where should I store a JWT in a browser app?
The safest option is an httpOnly cookie (JavaScript cannot access it, preventing XSS attacks). Storing in memory (a JavaScript variable) is also safe but the token is lost on page refresh. Never store JWTs in localStorage or sessionStorage if your app is vulnerable to XSS.
What is a refresh token?
A refresh token is a long-lived token used to get new short-lived access tokens. The access token (JWT) expires quickly (15 minutes to 1 hour). When it expires, the client sends the refresh token to get a new access token without requiring the user to log in again. Refresh tokens are stored securely and can be revoked individually.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.

See Programs