Skip to main content

Command Palette

Search for a command to run...

Sessions vs JWT vs Cookies

Understanding Authentication Approaches

Published
โ€ข8 min read
Sessions vs JWT vs Cookies
S

Frontend Developer ๐Ÿ’ป | Fueled by curiosity and Tea โ˜• | Always learning and exploring new technologies.

You've seen the Stack Overflow answers. You've skimmed the Medium posts. You know the drill sessions store state on the server, JWTs are stateless, cookies are just the transport. Got it.

But then you actually build auth and a weird bug shows up. A logged-out user still gets in. Your JWT is 8 kilobytes. Your "stateless" microservices are calling a central database on every request. Something is off.

This article is about those moments. The things tutorials skip. Let's go from the foundations up, but stop at the places where the real decisions live.

Cookies: Just a Box in the Truck

Here's the misconception that causes the most confusion: a cookie is not an authentication strategy. It's a storage mechanism a key-value pair the browser saves and automatically sends back with every request to the same domain.

That's it. The browser does the sending for you. You don't write any fetch logic to attach cookies to requests. This is both a convenience and the source of CSRF attacks.

Cookies predate modern JavaScript. They were designed for a world without fetch(). The browser's automatic attachment behavior is a legacy of that era โ€” and it's exactly what makes them dangerous if you don't set the right flags.

The Three flags you must set

Every developer knows cookies exist. Few consistently set all three of these on production auth cookies:

  • HttpOnlyPrevents JavaScript from reading the cookie. This is the single most effective mitigation against XSS stealing your session token. If you can access your auth cookie via document.cookie, you've already lost.

  • SecureOnly sends the cookie over HTTPS. Without this, your token travels in cleartext. You'd be surprised how many staging environments skip this and then wonder why tokens leak.

  • SameSite=Strict or LaxControls cross-site sending. Strict is safest โ€” the cookie won't go with any cross-origin request. Lax allows top-level navigations (clicking a link). The default used to be None, which is a CSRF nightmare. Modern browsers default to Lax now, but don't rely on that.

Storing a JWT inside localStorage gives it zero of the protections above. Any XSS vulnerability on your page can read it, send it anywhere, and impersonate your user indefinitely โ€” because JWTs don't expire until their exp claim says so.

Sessions: The Server Remembers You

Session-based authentication is the original approach. It works like a coat check at a restaurant. You hand over your coat (credentials), they give you a numbered ticket (session ID), and later you return the ticket to get the coat back.

The server stores the actual session data user ID, roles, cart contents, whatever in memory or a database. The client only holds the ticket. The ticket itself is meaningless without the server's ledger.

The critical thing to understand: the session ID that lives in the cookie is opaque. It's a random string. It carries zero information on its own. All the meaning lives server-side.

The "sessions don't scale" myth. You'll hear that sessions are stateful and therefore don't scale horizontally. This was true when sessions lived in server memory. Move session storage to Redis and suddenly you have a distributed, in-memory session store that handles hundreds of thousands of concurrent users. The real cost is the round-trip to Redis โ€” about 0.5โ€“2ms on a co-located instance. That's a completely acceptable trade-off for most applications.

JWT: The Server Trusts Your ID Card

JSON Web Tokens take a different philosophy. Instead of storing state on the server, you encode state into a token and send it to the client. The server signs the token cryptographically, so when the client sends it back, the server can verify the signature without looking anything up.

A JWT has three parts separated by dots: header.payload.signature. Base64URL-encoded. The payload is where your user data lives.

The most common JWT misconception: JWT is signed, not encrypted. Anyone can decode the payload โ€” just paste it into jwt.io. Never put sensitive data (passwords, PII, API keys) in the payload. The signature only proves it wasn't tampered with; it doesn't hide the contents.

This "no database lookup" property is genuinely useful in microservice architectures where many services need to know who the user is. Pass the JWT and every service can independently verify it using the same public key.

What "stateless" actually costs you

Here's where most tutorials stop โ€” but you shouldn't.

  • You can't revoke a JWT. If a user logs out, changes their password, or you ban them โ€” the JWT is still valid until it expires. The server has no record of it to invalidate. Your options: short expiry times (5โ€“15 minutes) with refresh tokens, or maintain a token blocklist โ€” which is just a session store under a different name.

  • Refresh tokens bring back state. The standard pattern is a short-lived access token + a long-lived refresh token. The refresh token lives in the database. So you now have server-side state again. You've solved one problem (DB lookup per request) while creating another (periodic DB calls to refresh). Know the trade-off you're making.

  • Token size hits your header limits. A typical JWT with a few claims is 500โ€“700 bytes. Add roles, permissions, and org context and you can easily hit 2โ€“4KB. Some proxies and CDNs have header size limits. Every request carries this overhead. Compare that to a 32-byte session ID.

  • Clock skew can expire tokens prematurely. JWTs use Unix timestamps for iat, nbf, and exp. If your servers aren't NTP-synced, a token signed on Server A might be rejected as expired or "not yet valid" by Server B. Most libraries include a small leeway (60s), but this bites teams with containerized environments that don't sync clocks properly.

  • Algorithm confusion attacks are real. The JWT header specifies which algorithm was used to sign the token. An early class of vulnerabilities involved setting the algorithm to none and removing the signature โ€” some libraries would accept this. Always explicitly specify which algorithms your library should accept. Never trust the header's algorithm claim blindly.

When to Use Each

"The best auth system is the one your team understands well enough to avoid shooting themselves in the foot."

Reach for Sessions when

  • You need instant account revocation (banking, enterprise SaaS)

  • Your app is a single domain โ€” no cross-subdomain auth needed

  • You change user roles frequently

  • You're a small team and want simple, proven auth

  • You have access to a shared cache (Redis) for your servers

Reach for JWT when

  • Multiple services need to verify identity independently

  • You have mobile clients or third-party API consumers

  • You're building an auth server (OAuth / OIDC)

  • Cross-domain or cross-subdomain auth is required

  • You can tolerate short windows of stale token data

Best Practices to follow while build Auth System

The instinct when using JWTs is to store them in localStorage it's JavaScript-accessible, simple to use, and feels "modern." But localStorage is readable by any script on your page. One XSS vulnerability and your token is gone. Store JWT in a HttpOnly cookie and you get the best of both worlds: stateless verification on the server, and XSS-resistant storage on the client.

The trade-off is that cookies bring CSRF exposure back. Handle it with SameSite=Strict and you've solved that too. This combination JWT + HttpOnly cookie + SameSite is what most security-conscious teams actually use in production.

The "sub" claim should be an opaque ID, not an email

The sub (subject) claim in JWT is supposed to identify who the token is for. Beginners often put the user's email address here. The problem: emails change. If a user changes their email and you're using it as a key, you've got a mismatch. Use an internal UUID or integer ID that never changes.

Don't put sensitive data in the JWT payload

Again, because this cannot be said enough: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJzc246IjEyMy00NS02Nzg5In0 is not encrypted. The middle section decodes to plain JSON. Never put SSNs, passwords, credit card tokens, or any PII in the payload. The signature proves authenticity; it doesn't provide confidentiality. If you need confidentiality, use JWE (JSON Web Encryption) but that's a separate spec and significantly more complex.

Set a sane expiry on sessions too

Developers obsess over JWT expiry but forget to set expiry on sessions. A session with no TTL in Redis (or no timeout in your session library) lives forever. A user who logged in three years ago and never explicitly logged out still has a valid session. Set both absolute expiry and idle timeout. Absolute: "this session is invalid after 30 days regardless." Idle: "this session is invalid if unused for 2 hours."

Authentication is one of those topics where the basics are well-documented, and the hard parts are learned through painful production incidents. The goal isn't to pick the "right" answer it's to pick the approach whose trade-offs your team understands completely.

Sessions give you control and simplicity. JWTs give you distribution and flexibility. Cookies give both a transport mechanism. None of them are magic. All of them have sharp edges. Now you know where to watch your fingers.

3 views
Sessions vs JWT vs Cookies