Skip to main content

Command Palette

Search for a command to run...

Sessions, Cookies, and JWT: The Complete Guide to Web Authentication

Updated
15 min read
S
Hi, I'm Shreshtha. I'm passionate about technology, problem-solving, and understanding how things work under the hood. Currently exploring JavaScript, Node.js, and backend development while documenting what I learn along the way. I love understanding how technology works behind the scenes and turning that learning into simple, practical explanations. I enjoy breaking down complex concepts into simple explanations and sharing insights from my learning journey. Through these blog, I write about programming, backend development, and ideas around technology and innovation. Always learning, always building.

Every time you log into a website and come back the next day without being asked to log in again, something behind the scenes is keeping track of who you are. That "something" is authentication state — and depending on how the server is built, it might be stored in a session, encoded into a token, or threaded through a cookie. Understanding how each of these pieces works, and how they relate to each other, is one of those fundamentals that makes everything else in backend development click.

This article walks through what sessions, cookies, and JWT tokens are, how stateful and stateless authentication differ, and — most importantly — when to actually reach for each one.


What Is a Session?

When a user logs in, the server needs a way to recognise them on every subsequent request. HTTP is stateless by design — each request arrives at the server with no memory of any previous request. Sessions are the oldest solution to this problem.

A session is a small piece of data stored on the server that represents an active, authenticated user. When you log in, the server creates a session object — typically containing your user ID and maybe some metadata like when the session was created — and saves it somewhere: in memory, in a database, or in a caching layer like Redis. The server then gives you back a session ID, a short random string that acts as a reference to that stored data.

On every subsequent request, you send that session ID back to the server. The server looks it up, finds your session data, and knows who you are. The actual data never leaves the server — the ID is just a key.

This is a fundamentally stateful model. The server is holding state on your behalf. If you invalidate that session — by logging out, or by the server expiring it — the ID becomes meaningless. The server is always in control.


A cookie is a small piece of data that the browser stores and automatically sends with every request to the same domain. That last part is important: automatic. You don't have to write any JavaScript to attach a cookie to a request. The browser does it for you.

When a server wants to store something in your browser, it sends a Set-Cookie header in its response. The browser saves it. From that point on, every request to that domain includes a Cookie header with that value.

What Is JavaScript Running in the Browser?

JavaScript running in the browser is client-side code — it executes inside the user's browser tab, not on your server. It can manipulate the DOM, make HTTP requests, and access browser storage like localStorage or document.cookie. This is important because any data accessible to browser JavaScript is also accessible to malicious scripts injected via XSS (Cross-Site Scripting) attacks, where an attacker manages to get their own JavaScript to execute on your page.

Cookies have a few important attributes worth knowing:

  • HttpOnly means the cookie cannot be accessed by JavaScript running on the page. Even if an attacker injects a malicious script that tries to read document.cookie, an HttpOnly cookie simply won't be there. This makes it the safest place to store a session ID.

  • Secure means the cookie is only sent over HTTPS, preventing it from being transmitted in plaintext over an unencrypted connection.

  • SameSite controls whether the cookie is sent with cross-site requests. This is your primary defence against CSRF (Cross-Site Request Forgery) — an attack where a malicious third-party website tricks a user's browser into making requests to your site without the user's knowledge (for example, visiting evil.com which fires a hidden POST to yourbank.com/transfer). With SameSite=Strict or SameSite=Lax, the browser refuses to include the cookie in such cross-origin requests.

These flags are what makes cookie-based authentication safe in practice — a bare cookie with none of these flags is an open invitation for trouble.

Yes. You could put the session ID in localStorage and attach it manually to each request using a header:

// Storing in localStorage
localStorage.setItem('sessionId', 'abc123xyz');

// Manually attaching to each request
fetch('/api/dashboard', {
  headers: {
    'X-Session-ID': localStorage.getItem('sessionId')
  }
});

However, localStorage is accessible to any JavaScript running on your page, which makes it vulnerable to XSS attacks. An HttpOnly cookie, by contrast, is automatically managed by the browser and is invisible to JavaScript entirely — making it the idiomatic and generally safer choice for session IDs.

How Cookies Relate to Sessions

Cookies and sessions are frequently confused because they're so often used together. The relationship is simple: the session ID is typically stored in a cookie. The session lives on the server; the cookie is just the delivery mechanism for the session ID that the browser ferries back and forth.


What Is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a self-contained token that encodes information directly inside itself. Rather than the server storing your session data and giving you a key, a JWT is the data — signed and packaged so the server can verify it hasn't been tampered with.

The Three Parts of a JWT

A JWT has three parts, separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyX2FiYzEyMyIsIm5hbWUiOiJTaHJlc2h0aGEiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTQwMDAwMDAsImV4cCI6MTcxNDA4NjQwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header — contains metadata about the token, specifically the signing algorithm:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload — the interesting part. A JSON object containing claims — statements about the user or the token itself. Standard claims include sub (subject, usually a user ID), iat (issued at), and exp (expiration time). You can add any custom claims you need, like roles or permissions:

{
  "sub": "user_abc123",
  "name": "Shreshtha",
  "role": "admin",
  "iat": 1714000000,
  "exp": 1714086400
}

Signature — this is what makes the token trustworthy. The server signs the header and payload together using a secret key (or a private key, for asymmetric algorithms):

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  your-secret-key
)

When the server receives a token later, it re-runs this signing operation and compares the result to the signature in the token. If they match, the payload hasn't been altered. If someone tries to change the sub from user_abc123 to admin_001, the signature will no longer match and the token will be rejected.

Encoding vs. Encryption — Why JWT Is Not Encrypted

This is one of the most important and misunderstood aspects of JWTs.

Encoding (what JWT uses) is a reversible transformation that changes the format of data without protecting its content. Base64URL encoding just converts binary data to a URL-safe string. Anyone with the token can decode and read the payload — no key required.

Encryption transforms data so that it can only be read by someone with the correct decryption key. Even if you have the data, it looks like gibberish without the key.

JWT uses Base64URL encoding, not encryption. This means if you get hold of a JWT, you can paste it into jwt.io and read the entire payload in plaintext. The signature prevents tampering, but provides zero confidentiality.

Rule of thumb: Never put sensitive information — passwords, payment details, personal data — inside a JWT payload. Anyone who intercepts the token can read it.


Session Authentication Flow

Here's what happens end to end in a session-based authentication system:

  1. The user submits their credentials (email and password) in a login form.

  2. The server validates the credentials against the database.

  3. On success, the server creates a session record and stores it in a session store (Redis, database, etc.) with a generated session ID as the key.

  4. The server responds with a Set-Cookie header containing the session ID.

  5. The browser stores the cookie and sends it automatically with every subsequent request.

  6. On each protected request, the server reads the session ID from the cookie, queries the session store, and retrieves the associated user data.

  7. When the user logs out, the server deletes the session record. The session ID in the cookie becomes useless.


JWT Authentication Flow

Here's how the same process works with JWTs:

  1. The user submits their credentials.

  2. The server validates them against the database.

  3. On success, the server generates a JWT — it signs a payload containing the user's ID and any claims (like role or permissions) using a secret key, and sets an expiry.

  4. The server sends the JWT back to the client. The client stores it — ideally in an HttpOnly cookie, or in memory for SPAs.

  5. On every subsequent request, the client sends the JWT either as a cookie or in the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyX2FiYzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxNDA4NjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The Bearer prefix is a convention indicating the request is authenticated via a bearer token — meaning whoever holds the token is granted access.

  1. The server receives the token, verifies the signature, checks the expiry, and reads the payload. No database lookup required.

  2. When the user "logs out", the token is discarded on the client. But it remains technically valid on the server until it expires — which is why short expiry times matter.

What Is a SPA?

A Single-Page Application (SPA) is a web app that loads a single HTML page and dynamically updates the content using JavaScript, without full page reloads. Examples include Gmail, Notion, and most modern React or Vue apps. SPAs typically communicate with backend APIs via fetch or axios, and since they're not traditional server-rendered pages, storing tokens in memory (a JavaScript variable) rather than a cookie can sometimes make sense — though HttpOnly cookies remain the safer default.

Access Tokens and Refresh Tokens

A common pattern is to use two tokens together: a short-lived access token (15 minutes to 1 hour) for authentication, and a longer-lived refresh token stored securely, used only to obtain new access tokens. This balances the security risk of token theft with the inconvenience of frequent logins.


Stateful vs Stateless Authentication

These two terms describe where authentication state lives.

Stateful Authentication (Sessions)

Consider this analogy: you walk into a library, hand over your ID, and the librarian creates a record at their desk: "Member #4821 is currently inside." They give you a numbered token. Every time you want to check out a book, they look up your number in their records to confirm you're a valid member. When you leave, they delete the record. The token is just a reference — all the real information is on their side.

In software terms: The server holds the state. Every active session has a corresponding record in storage. When a request comes in, the server performs a lookup — it takes the session ID, queries its storage, and retrieves the session data.

This lookup is the defining characteristic of stateful auth. It means the server has complete control over every session. You can invalidate a session instantly by deleting it from storage. You can see all active sessions for a user. You can enforce concurrency limits. The tradeoff is that every authenticated request requires a database or cache lookup, and every server instance needs access to that shared session store.

Stateless Authentication (JWT)

Now imagine a different library: instead of creating a record, the librarian gives you a laminated card with your information printed on it, signed with an official stamp. Any librarian at any branch can look at the card, verify the stamp is authentic, and know who you are — without calling headquarters to check. When your card expires, you get a new one.

In software terms: The client holds the state. The token itself contains everything the server needs to verify the request. There's nothing to look up — the server just validates the signature and reads the payload.

This is the promise of JWTs. A server that uses JWT-based auth can verify a token without touching a database. It can scale horizontally without worrying about shared session storage.

The tradeoff is that you give up control. Once a JWT is issued, you can't easily revoke it — at least not without re-introducing some form of server-side state, like a token blacklist, which partially defeats the purpose.

What Is Horizontal Scaling?

When your application gets more traffic, you have two ways to handle it:

Vertical scaling means upgrading your existing server (more CPU, more RAM). There's a hard ceiling on how far this goes.

Horizontal scaling means adding more server instances. Instead of one powerful machine, you run five (or fifty) identical ones behind a load balancer that distributes incoming requests across them.

With session-based auth, this creates a problem: if a user's session is stored in Server A's memory, and their next request goes to Server B, Server B has no idea who they are. You have to either use sticky sessions (always route the same user to the same server, which defeats the purpose of load balancing) or maintain a shared session store (like Redis) that all server instances can query.

With JWT-based auth, there's no problem at all. Each server instance independently verifies the token's signature using the same secret key. No coordination is needed. A request can land on any of your fifty servers and be authenticated correctly.

This is why JWTs are particularly well-suited to microservices architectures. Imagine a system with an authentication service, an orders service, and a notifications service — potentially deployed in different regions:

  • A user logs in via the auth service in region A, which mints a JWT.

  • That user's request later hits the orders service in region B.

  • The orders service verifies the token's signature using the same secret key it already has — no call back to region A, no shared database, no coordination.

  • The same token works seamlessly across every service in your infrastructure.

With session-based auth, you'd need every service to reach back to a central session store, introducing latency and a single point of failure.


Session Auth vs. JWT: A Direct Comparison

Session-based auth JWT-based auth
State lives on Server (DB or cache) Client (token payload)
Model Stateful Stateless
DB lookup per request Yes — session store query No — signature verify only
Horizontal scaling Needs shared session store (extra infra) Any server can verify (scales easily)
Revocation Instant — delete the record (full control) Hard — token lives until exp (needs blacklist)
Token / payload size Tiny (session ID only) Larger (full payload per request)
Security on theft Invalidate session immediately Valid until expiry — use short exp
Best transport HttpOnly cookie HttpOnly cookie or memory
Complexity Simpler mental model Signing, rotation, refresh tokens
Best for Traditional web apps, admin panels, banking (high control) APIs, microservices, mobile/SPAs (high scale)

One thing the table above makes clear is that JWT is not automatically "better" than sessions. They solve different problems and come with different tradeoffs.


When to Use Each

Reach for session-based authentication when:

You're building a traditional server-rendered application where all your traffic goes through one server or a small cluster with shared storage. Sessions are simpler, give you fine-grained control over user access, and let you invalidate access instantly — essential for high-security applications like banking or admin dashboards where you cannot afford to have a stolen token remain valid.

Sessions are also the right choice when you need to track concurrent logins, enforce single-device access, or give users the ability to see and terminate all their active sessions.

Reach for JWT when:

You're building a stateless API that needs to scale horizontally, or a microservices architecture where multiple services need to verify identity without a shared session store.

JWTs also make sense when you're building a backend that serves multiple clients — a web app, a mobile app, a CLI — where cookies aren't a natural fit and you want a single, portable authentication mechanism.

That said: if you're using JWTs and you find yourself building a token blacklist, adding a lookup on every request, and wrestling with refresh token rotation — take a step back and ask if sessions with Redis would have been simpler. Often, they would be.


Key Takeaways

Sessions store state on the server and hand the client a key — they're stateful, instantly revocable, and great for traditional web apps.

Cookies are just the delivery mechanism for that key — not the authentication system itself. Their security attributes (HttpOnly, Secure, SameSite) are what make them safe for this job.

JWTs embed state in the token and push it to the client — they're stateless, scalable, and ideal for APIs and distributed systems, but at the cost of fine-grained revocation control. They're encoded (readable by anyone), not encrypted — so never put sensitive data in them.

The stateful vs stateless distinction is the heart of it. Stateful auth gives you control but requires shared infrastructure. Stateless auth gives you scalability but transfers trust to the token, which you can't easily take back.

Neither approach is universally superior. The right choice depends on your architecture, your scaling requirements, and how much control you need over active sessions. Understanding this distinction is what lets you make that choice with confidence rather than just following convention.


Follow along on the blog at webdevdeepdive.hashnode.dev for more backend deep dives.

1 views

Web Development

Part 6 of 7

📌 Description This series documents my journey into backend development, where I break down concepts not just by what they do, but how and why they work under the hood. Instead of treating backend as a black box, I focus on: Understanding internal workings (like how servers handle requests, data flow, etc.) Connecting concepts to real-world systems and analogies Writing and analyzing code step-by-step Each article is designed to take you from confusion to clarity — whether it's HTTP methods, APIs, server architecture, or deeper backend fundamentals. 🎯 What You’ll Learn . Core backend concepts explained intuitively . How things actually work behind the scenes . Practical code implementations with breakdowns . Real-world parallels to simplify complex ideas 🚀 Who This Is For Beginners starting backend development Frontend developers curious about what happens “behind the API” Anyone who wants deeper clarity instead of surface-level tutorials 📖 Approach I’m learning and building in public — which means this series is honest, evolving, and focused on true understanding rather than memorization.

Up next

From Passwords to Protocols: A Developer's Guide to Modern Authentication

Authentication is one of those topics where most developers learn just enough to use a library, and then move on. That works — until it doesn't. When something breaks, when you need to integrate with