Skip to main content

Command Palette

Search for a command to run...

Sessions, Cookies, and JWTs: How I Stopped Confusing Authentication With Identity

Published
7 min read
Sessions, Cookies, and JWTs: How I Stopped Confusing Authentication With Identity
S
Trying to transition my career to explore new things, new tech

I spent an embarrassing amount of time thinking authentication was just "checking if the password is correct." It is not. Checking the password is one moment. Authentication is the ongoing question: how does the server know who you are on the second request, the third, the fiftieth?

HTTP has no memory. Every request your browser sends is a stranger knocking on a door. The server does not know if this is the same person who logged in ten seconds ago or someone else entirely. The protocol was built in 1991 to serve documents, not to remember people. That design choice is the reason sessions, cookies, and tokens exist.

The real question is not "how do I check a password." It is: after I check the password once, how do I avoid asking again on every single request?

Browser                              Server
  |                                    |
  |--- POST /login (email, pass) ----->|
  |                                    | checks password... correct
  |<--- 200 OK, here is your data -----|
  |                                    |
  |--- GET /dashboard --------------->|
  |                                    | who is this? no idea.
  |<--- 401 Unauthorized --------------|

The login worked. The very next request fails because HTTP forgets.

Two families of solutions exist. One says the server remembers you. The other says the server gives you a signed note that proves who you are, and you carry it around. That is the session vs JWT split, and everything else is details.


Cookies: the envelope, not the letter

People mix up cookies with sessions constantly. A cookie is just a transport mechanism. It is a small piece of text that a server sends to a browser, and the browser automatically sends it back on every future request to that server. The cookie does not know what it is carrying. It could hold a session ID, a JWT, or your preferred theme color.

res.cookie('sid', 'abc123', {
  httpOnly: true,   // JavaScript cannot read this cookie
  secure: true,     // only sent over HTTPS
  sameSite: 'lax',  // restricts cross-site sending
  maxAge: 3600000,  // 1 hour
});

httpOnly is the flag that matters most. It hides the cookie from all JavaScript on the page, including malicious scripts injected through XSS attacks. The cookie only travels in HTTP headers, invisible to document.cookie.

Cookies are not an authentication method. They are an envelope. The question is what you put inside.


Sessions: the server remembers you

Session auth works like a valet parking. You walk in, hand over your car keys, get a numbered ticket. The cR KEYS stays with them. When you show the ticket, they find your car. When you leave, you hand back the ticket and they forget about you.

Replace "car key" with "your user data" and "ticket" with "session ID."

In Express:

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

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000,
  },
}));

app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email }).select('+password');
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const match = await user.comparePassword(req.body.password);
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });

  req.session.userId = user._id;
  req.session.role = user.role;
  res.json({ name: user.name, email: user.email });
});

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

The browser never sees or stores user data. It holds a meaningless session ID string. The server holds everything else. And because the server holds it, the server can delete it at any time. User changes their password? Destroy the session. Suspicious login? Destroy it. That instant revocation is the main thing sessions give you.


JWTs

JWT (JSON Web Token) works differently. Instead of keeping your data and giving you a reference to it, the server writes your data into a token, signs it with a secret key, and hands it to you. On every request, you show the token. The server verifies the signature, reads the data out of the token, and never looks anything up.

Think of it as a stamped letter of introduction. Someone writes "This is Priya, she is a customer, valid until 3pm," stamps it with an official seal, and gives it to her. Anyone who recognizes the seal trusts the letter without calling anyone.

A JWT has three parts separated by dots: header, payload, signature.

// Payload (base64 decoded, readable by anyone)
{
  "userId": 42,
  "role": "customer",
  "exp": 1735086400   // expiry timestamp
}

The payload is not encrypted. Anyone can decode it. The signature does not hide the data. It prevents tampering. If someone changes role from "customer" to "admin", the signature will not match and the server rejects the token. Do not put passwords or sensitive data in a JWT. It is signed, not sealed.

In Express:

import jwt from 'jsonwebtoken';

function generateToken(user) {
  return jwt.sign(
    { userId: user._id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );
}

app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email }).select('+password');
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const match = await user.comparePassword(req.body.password);
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });

  res.json({ token: generateToken(user) });
});

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token' });
  }
  try {
    req.user = jwt.verify(header.split(' ')[1], process.env.JWT_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

No session store. No database lookup for authentication. The server reads user data straight from the token.

The catch: once a JWT is issued, the server cannot take it back. There is no session to delete. The token lives until it expires. If you need to ban a user instantly, you either maintain a blacklist of revoked tokens (which re-introduces server-side state) or you accept a window where the token is still valid.


When to use which

Sessions JWTs
User data lives On the server Inside the token
Browser holds Meaningless session ID Readable payload + signature
Instant revocation Yes, delete the session No, valid until expiry
Works across services Needs shared session store Each service verifies independently
XSS risk Lower (httpOnly cookie hides ID) Higher if stored in localStorage
CSRF risk Yes (cookies sent automatically) No if using Authorization header

Three questions I ask on every new project:

Is the client a browser talking to one backend? Sessions. Less code, instant revocation, express-session handles it.

Do multiple services need to verify identity independently? JWTs. No shared state between services.

Is instant revocation non-negotiable? Sessions. JWT blacklisting works but you lose the stateless advantage.

For the food delivery app in this series, sessions win. One backend, browser clients, and the ability to kill sessions immediately when a password changes. If I were building an API consumed by a mobile app and three microservices, JWTs.


Mistakes to avoid regardless of which you pick

Use HTTPS in production. Without it, anyone on the same wifi can copy your cookie or token in transit. Free certificates from Let's Encrypt have existed for years.

Hash passwords with bcrypt.

If using JWTs in a browser, put them in httpOnly cookies, not localStorage. Most tutorials show localStorage because it is fewer lines. It is also readable by any JavaScript on the page, including injected scripts.

Keep JWT payloads small. User ID and role. Not the full user profile.

Do not put secrets in your Git repo. Use .env files and add them to .gitignore.


References


Next: middleware. What it actually is, how Express processes requests through a chain, and why the order of app.use() calls matters more than you think.

More from this blog

S

Saumya Agrawal

57 posts