Session Tokens vs JWTs: The False Dichotomy

Published: (January 2, 2026 at 05:56 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

The “JWT‑only” Approach

// User logs in
const jwt = sign(
  { userId: user.id, role: user.role },
  SECRET,
  { expiresIn: '7d' }
);
setCookie('token', jwt);

// Every request
const payload = verify(req.cookies.token, SECRET);
// Done. No database hit.

Pros

  • No database queries – every server can verify the token.
  • “Stateless” authentication (whatever that means).

Cons

  • Revocation problem – If a user is deleted or fired, their JWT is still valid for up to 7 days.
  • Stale role data – Changing a user’s role doesn’t affect already‑issued tokens.
  • No “log out everywhere” – Stolen laptops, lost devices, etc., keep their tokens alive until expiration.

Result: Fast (≈0.97 ms per request) and high‑throughput (≈5,527 req/s), but you can’t revoke tokens and data becomes stale instantly.

The “Session‑only” Approach

// User logs in
const sessionId = randomBytes(32).toString('hex');
await db.session.create({
  id: sessionId,
  userId: user.id,
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
setCookie('sessionId', sessionId);

// Every request – database lookup
const session = await db.session.findUnique({
  where: { id: req.cookies.sessionId },
  include: { user: true }
});

if (!session || session.expiresAt /* … */) {
  // handle missing or expired session
}

Result: Safe but slow; the database becomes the limiting factor.

Why the Hybrid Approach Works

The hybrid method only touches storage when the access token expires (e.g., ~1 % of requests). For the remaining 99 % of requests you get JWT‑level speed with no network call.

A Quick Note on Redis

If you replace PostgreSQL with Redis for session storage, lookups drop to ~2–3 ms, but you’re still making an external call on every request. The hybrid approach eliminates even that call for the vast majority of traffic.

The “Microservices” Counter‑Argument

“Microservices don’t share a database, so JWTs are the only way to validate tokens independently.”

In practice, microservices already share:

  • A database (or cluster) for persistence
  • Redis (or another cache) for shared state
  • Message queues, logging, monitoring, etc.

If you already have Redis for caching, session validation is cheap. The hybrid approach still wins by avoiding the network round‑trip for 99 % of requests.

The Solution: Short‑Lived Access Tokens + Long‑Lived Refresh Tokens

It’s not JWT vs. sessions; it’s JWT + sessions, each doing what it does best.

How It Works

// User logs in
async function login(email, password) {
  const user = await authenticateUser(email, password);

  // Refresh token – stored in DB (valid for 30 days)
  const refreshToken = randomBytes(32).toString('hex');
  await db.session.create({
    userId: user.id,
    refreshToken,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
  });

  // Access token – NOT stored, just a JWT (valid for 15 minutes)
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    SECRET,
    { expiresIn: '15m' }
  );

  // Send tokens to the client
  res.cookie('accessToken', accessToken, { httpOnly: true });
  res.cookie('refreshToken', refreshToken, { httpOnly: true });
}
  • Access token (JWT) – short‑lived, verified locally, no DB/Redis hit.
  • Refresh token (session) – long‑lived, stored in DB/Redis, used only when the access token expires or when the user explicitly logs out.

Refresh Flow (simplified)

// Refresh endpoint
async function refresh(req, res) {
  const { refreshToken } = req.cookies;
  const session = await db.session.findUnique({
    where: { refreshToken },
    include: { user: true }
  });

  if (!session || session.expiresAt /* … */) {
    // handle missing or expired refresh token
    return;
  }

  // Issue a new short‑lived access token
  const newAccessToken = jwt.sign(
    { userId: session.user.id, role: session.user.role },
    SECRET,
    { expiresIn: '15m' }
  );

  res.cookie('accessToken', newAccessToken, { httpOnly: true });
}

Takeaway: Session‑only turns your auth system into a DB bottleneck. Hybrid retains JWT speed while adding session‑style control.

Full benchmark results are in stat-tests/RESULTS.md, and the test code is available at stat-tests/test-three-auth-strategies.

Recommendations

  • Use the hybrid approach for virtually everything. It gives you JWT‑level speed with session‑level security.
  • JWT‑only only when tokens are extremely short‑lived (< 5 min) and you truly don’t care about revocation (rare).
  • Session‑only only for tiny apps (< 10 req/sec) where simplicity outweighs performance concerns.

You don’t have to pick sides. Get the speed of JWTs and the control of sessions—just think about the trade‑offs instead of cargo‑culting a single approach.

Happy hacking!

Back to Blog

Related posts

Read more »

Spring Security 시작하기 - 기본 설정과 인증

기본 설정 의존성 추가 Spring Security를 사용하려면 의존성만 추가하면 됩니다. 추가하는 것만으로 기본 보안이 활성화됩니다. Maven xml org.springframework.boot spring-boot-starter-security Gradle gradle imple...