Security

Authentication vs Authorization: Sessions, JWTs, Refresh Token Rotation, and When JWT Is the Wrong Tool

Learn how authentication differs from authorization, how sessions and JWTs work, why refresh tokens rotate, and when a session beats a JWT.

Aarav Sharma

Aarav Sharma

October 31, 20266 min read
Share
Authentication answers who the user is and authorization answers what the user can do

You add login to an app, someone on the team says "just use JWT", and a week later you are writing a token denylist because logout does not actually log anyone out. Most of that pain comes from mixing up two different questions and picking a tool before naming the requirement.

Authentication answers "who are you?". Authorization answers "what are you allowed to do?". The tool you use to remember the answer to the first question between requests — a server session or a signed token — is a separate decision again.

This post walks through both concepts, how sessions and JWTs keep a user logged in, how refresh token rotation catches stolen tokens, and when a JWT is simply the wrong tool.

Quick Answer

Authentication verifies who the user is; authorization decides what that user may do, and you need both on every protected request. Server sessions keep login state on the server and give you instant logout and permission changes. JWTs carry signed claims that any service can verify without a lookup, which helps across multiple services but makes revocation your problem. If you use JWTs, keep access tokens short-lived and rotate refresh tokens with reuse detection. For a single backend serving a browser app, a plain server session is usually the simpler and safer choice.

What Is Authentication vs Authorization

Picture an admin dashboard. A user submits an email and password, and the server confirms they match an account. That is authentication: the user's identity is now established.

The same user then requests /admin/users. The server checks whether that identity holds an admin role. That is authorization, and it runs on every request, not once at login.

A request passes authentication, which returns 401 if the user is unknown, then authorization, which returns 403 if the action is not allowed, before reaching the handler

Being authenticated does not mean being allowed to do everything. The OWASP Authorization Cheat Sheet treats the two as separate concerns and recommends denying by default. A JWT can tell your backend who the user is, but it does not decide what they may do — your application still does that.

The Problem

HTTP requests are independent. Once POST /login succeeds, the next request arrives with no memory of it, so the server needs something the client sends back to prove it is the same user.

There are two common answers: a server-side session or a self-contained token. Teams often reach for JWT by default because it sounds modern, then rediscover the hard parts — logout, role changes, and stolen tokens — one production incident at a time.

Server sessions keep only an ID in the cookie and the state on the server, while JWTs carry signed claims that stay valid until they expire unless revocation is added

How a server session works

After login, the server creates a session record and sends back only its ID in a cookie:

Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax

The user ID, role and expiry stay on the server. On the next request the browser sends the cookie, and the backend looks up the session. To log the user out, you delete the record — the cookie becomes worthless immediately. The OWASP Session Management Cheat Sheet covers expiry, renewal and invalidation for this model.

How a JWT works

A JSON Web Token is three base64url parts — header, payload, signature — joined by dots. The payload carries claims:

{
  "sub": "101",
  "role": "admin",
  "exp": 1780000000
}

The client sends it as Authorization: Bearer <token>, and any service holding the key can verify the signature without a database call. That is the whole appeal, and also the catch: once issued, the token stays valid until exp, whatever happens in your database.

The Solution

Treat the choice as three steps: keep the two checks separate, pick the state model your architecture actually needs, and if that model is tokens, add the safeguards tokens require.

Step 1: Separate the two checks

Write authentication and authorization as separate middleware so each does one job. Authentication answers 401; authorization answers 403.

function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ message: 'Access denied' });
    }
    next();
  };
}

app.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);

Hiding the delete button in the frontend is a UI nicety, not a control. The API must enforce the permission, because anyone can send DELETE /users/123 directly.

Step 2: Use short-lived access tokens with refresh tokens

If you go with tokens, do not issue one long-lived JWT. Issue a short-lived access token for API calls and a refresh token whose only job is getting a new access token. A stolen access token then has a small useful window, and the refresh token never touches your regular APIs.

Step 3: Rotate refresh tokens and detect reuse

A refresh token that stays valid forever is effectively a password. With rotation, every refresh returns a new refresh token and revokes the old one. The server remembers which tokens belong to the same login — the token family.

Login issues refresh token A, a refresh revokes A and issues B, a replay of A is detected as reuse, and the server revokes the whole family so the user must log in again

If a revoked token ever comes back, someone is replaying it. You cannot tell whether it is the attacker or the real user, so you revoke the whole family and force a fresh login. RFC 9700, the OAuth 2.0 Security Best Current Practice, describes exactly this approach.

Here is a complete, runnable version. Save it as rotation.js, run npm install express jsonwebtoken, then node rotation.js:

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const ACCESS_SECRET = crypto.randomBytes(32).toString('hex');
const refreshTokens = new Map(); // token -> { userId, family, revoked }

function issueTokens(userId, family = crypto.randomUUID()) {
  const accessToken = jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: '15m' });
  const refreshToken = crypto.randomBytes(32).toString('hex');
  refreshTokens.set(refreshToken, { userId, family, revoked: false });
  return { accessToken, refreshToken };
}

function revokeFamily(family) {
  for (const record of refreshTokens.values()) {
    if (record.family === family) record.revoked = true;
  }
}

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

app.post('/login', (req, res) => {
  // Replace with a real password check (e.g. bcrypt.compare)
  if (req.body.password !== 'demo') {
    return res.status(401).json({ message: 'Invalid credentials' });
  }
  res.json(issueTokens('101'));
});

app.post('/refresh', (req, res) => {
  const record = refreshTokens.get(req.body.refreshToken);
  if (!record) return res.status(401).json({ message: 'Unknown token' });

  if (record.revoked) {
    revokeFamily(record.family);
    return res.status(401).json({ message: 'Token reuse detected, log in again' });
  }

  record.revoked = true;
  res.json(issueTokens(record.userId, record.family));
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

Try it: log in, refresh once, then send the first refresh token again. The second call returns 401, and the newer token is now dead too:

R1=$(curl -s -X POST localhost:3000/login -H 'Content-Type: application/json' \
  -d '{"password":"demo"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).refreshToken')
curl -s -X POST localhost:3000/refresh -H 'Content-Type: application/json' -d "{\"refreshToken\":\"$R1\"}"
curl -s -X POST localhost:3000/refresh -H 'Content-Type: application/json' -d "{\"refreshToken\":\"$R1\"}"

In production the Map becomes a database table, and the refresh token is stored hashed, the same way you store passwords.

Step 4: Lock down the rest

A few rules apply whichever model you pick:

  • Serve everything over HTTPS, so cookies and tokens never travel in plain text.
  • Set HttpOnly, Secure and SameSite on auth cookies.
  • Keep tokens out of localStorage. Any script on the page can read it, and the OWASP HTML5 Security Cheat Sheet warns against storing session identifiers or tokens there.
  • Validate every JWT claim you rely on: signature, iss, aud, exp, nbf and sub — not just that the string looks like a JWT.
  • Regenerate the session ID after login and after any privilege change.
  • Log login, logout, refresh and every detected reuse, so you can investigate later.

Trade-offs

JWT is the wrong tool when you have one backend serving a browser app you control. A session lookup is cheap, and you gain nothing from verifying signatures locally while taking on rotation, revocation and claim validation.

JWT is also the wrong tool when you need instant logout or permissions that change often. A token that says "role": "admin" keeps saying it after you demote the user, until it expires. The usual fix is a denylist, and the OWASP JSON Web Token Cheat Sheet points out that once you add one, your "stateless" system has state again.

Sessions have their own costs. With several backend instances you need a shared session store such as Redis, and every request pays for that lookup. Cookie-based sessions also need CSRF protection, which bearer tokens in a header do not.

JWT earns its place when several independent services must verify the same identity without calling a central store, when your API serves many kinds of clients, or when you integrate with OAuth and OpenID Connect providers that already issue JWTs. Even then, check high-risk actions against current server-side permissions rather than trusting an old claim.

Neither approach is more secure by default. Security comes from the implementation, and the simpler model is easier to get right.

Conclusion

On Monday, look at your login flow and write down which problem your current choice solves. If you run one backend and your JWT exists only because it is popular, consider replacing it with an HttpOnly session cookie. If you genuinely need tokens, check three things today: access tokens expire within minutes, refresh tokens rotate with reuse detection, and every protected route has an explicit authorization check on the server. If you want a second pair of eyes on your auth design, Contact us.

Aarav Sharma

Aarav Sharma

Lead Software Engineer

Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts