
Sessions, cookies, and JWTs are three concepts that constantly get conflated. This post untangles what each one actually is, how they interact, and when you should reach for each approach.
Think about a hotel. When you check in, the receptionist verifies your identity and hands you a keycard. That keycard is all you need to access your room, the gym, the pool. Now, two different hotels might handle this differently. One keeps a record at the front desk of exactly who has which keycard, and checks that record every time you use it. Another encodes your access permissions directly onto the keycard chip, so no lookup is needed. Both work. They just have different tradeoffs. That's the difference between session-based and token-based authentication, and cookies are just the envelope you carry the keycard in.
Before anything else, a correction to how most people learn this topic: cookies are not an authentication mechanism. They are a browser storage and transport mechanism. A cookie is just a small piece of data that a server tells the browser to store and automatically send back on every subsequent request to that domain.
Server Response: Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict Browser stores it. Next Request to Same Domain: Cookie: sessionId=abc123 ← browser sends this automatically
That's it. The cookie just carries whatever value you put in it. You could store a session ID in a cookie. You could store a JWT in a cookie. You could store a random string. The cookie is the envelope. What you put inside determines the auth strategy.
The important attributes:
HttpOnly: JavaScript cannot read the cookie. Prevents XSS attacks from stealing it.Secure: Cookie only sent over HTTPS.SameSite: Controls whether the cookie is sent on cross-site requests, which prevents CSRF attacks.Session-based authentication is stateful. The server remembers you.
When you log in, the server creates a session object, stores it somewhere (memory, a database like Redis, a file), and gives you back a session ID. That ID goes into a cookie. On every subsequent request, your browser sends the cookie, the server looks up the session ID, finds your stored data, and knows who you are.
┌──────────┐ ┌──────────┐ ┌─────────┐ │ Browser │ │ Server │ │ Store │ └────┬─────┘ └────┬─────┘ └────┬────┘ │ │ │ │ POST /login {email, password} │ │ │────────────────────────────────────>│ │ │ │ store session │ │ │───────────────────>│ │ │ sessionId: xyz │ │ Set-Cookie: sessionId=xyz │ │ │<────────────────────────────────────│ │ │ │ │ │ GET /dashboard (Cookie: xyz) │ │ │────────────────────────────────────>│ │ │ │ lookup sessionId │ │ │───────────────────>│ │ │ returns user data │ │ 200 OK + user data │<───────────────────│ │<────────────────────────────────────│ │
The session store is the source of truth. To invalidate a session, you delete it from the store. Logout is immediate and complete.
JWT stands for JSON Web Token. It's stateless authentication: the server doesn't store anything. Instead, all the information needed to identify you is encoded directly into the token itself.
A JWT has three parts, separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI0MiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c │ │ │ Header Payload Signature (algorithm) (your data) (proves it wasn't tampered with)
The payload contains claims: who you are, when the token was issued, when it expires, what roles you have. The signature is generated by the server using a secret key. Anyone can decode the header and payload (it's just base64). But without the secret key, you can't forge a valid signature.
┌──────────┐ ┌──────────┐ │ Browser │ │ Server │ └────┬─────┘ └────┬─────┘ │ │ │ POST /login {email, password} │ │────────────────────────────────────>│ │ │ create JWT, sign it │ │ (no storage needed) │ JWT token in response │ │<────────────────────────────────────│ │ │ │ GET /dashboard │ │ Authorization: Bearer <token> │ │────────────────────────────────────>│ │ │ verify signature │ │ decode payload │ │ no DB lookup needed │ 200 OK + user data │ │<────────────────────────────────────│
The server verifies the signature on every request. If valid, it trusts the payload. No lookup, no shared state.
JWTs are often sent in the Authorization header as Bearer <token>, but they can also be stored in a cookie, at which point you get the transport benefits of cookies (HttpOnly, Secure) with the stateless nature of JWT.
This is the fundamental difference, and everything else flows from it.
┌──────────────────────────────────┬──────────────────────────────────┐ │ Stateful (Sessions) │ Stateless (JWT) │ ├──────────────────────────────────┼──────────────────────────────────┤ │ Server stores session data │ Server stores nothing │ │ Session ID in cookie │ All data lives in the token │ │ DB/cache lookup on every request │ Signature verification only │ │ Instant revocation │ Token valid until expiry │ │ Single server or shared store │ Works across multiple servers │ └──────────────────────────────────┴──────────────────────────────────┘
The session model is like a coat check. You hand in your coat, get a ticket, and they hold it. Revocation is easy: just discard the coat. But the coat check needs to exist and be accessible.
The JWT model is like a passport. The passport carries your information and a government stamp. Anyone who can verify the stamp trusts it without calling the passport office. Revocation is hard though: you can't "un-issue" a passport mid-validity without a separate blocklist.
Revocation:
Sessions win here by a wide margin. Delete the session from the store, the user is logged out immediately. With JWT, if a token has a 1-hour expiry and the user gets compromised or logs out, that token technically remains valid until it expires unless you maintain a blocklist. That blocklist reintroduces state, partially defeating the purpose.
Scalability:
JWT has the edge. With sessions, every server in your cluster needs access to the same session store, which means a shared Redis instance or a sticky session setup. With JWT, any server can verify the token independently since validation only requires the secret key, not a database.
Payload size:
JWT tokens carry data in every request. A session cookie is just a short ID. For applications with large user objects or many roles, JWT payloads can become meaningfully large.
Visibility of data:
JWT payloads are base64-encoded, not encrypted by default. Anyone who intercepts a JWT can read the payload. Don't put sensitive data there. If you need encryption, that's JWE (JSON Web Encryption), which is a separate layer.
Pure JWT with long expiry is a security risk. The common solution is short-lived access tokens paired with long-lived refresh tokens:
Access Token: expires in 15 minutes (sent with every API request) Refresh Token: expires in 7 days (stored in HttpOnly cookie, used only to get a new access token) Flow: 1. Access token expires 2. Client sends refresh token to /auth/refresh 3. Server validates refresh token, issues new access token 4. Client retries original request
This limits the damage window if an access token is stolen, while keeping the user logged in across sessions.
There's no universally correct answer. It depends on your architecture.
Use sessions when:
Use JWT when:
Use cookies for transport when:
HttpOnly and Secure are safer than localStorage for storing tokens, regardless of whether you're doing sessions or JWT.SameSite.Avoid localStorage for sensitive tokens. It's accessible to any JavaScript on the page, including third-party scripts. An XSS vulnerability anywhere on the page can exfiltrate tokens stored there.
Starting a new auth system? │ ├── Browser-only web app? │ │ │ ├── Server-rendered (Rails, Django, Express + EJS)? │ │ └── Sessions + HttpOnly cookie │ │ │ └── SPA (React, Vue, Next.js)? │ └── JWT in HttpOnly cookie + refresh token │ └── API for mobile / multiple clients? │ ├── Single service? │ └── JWT with short expiry + refresh tokens │ └── Microservices / SSO needed? └── JWT (or delegate to an identity provider)
HttpOnly cookies, not localStorage.Authentication is one of those areas where understanding the tradeoffs matters more than picking the "right" answer. Both approaches are production-proven. The question is which tradeoffs match your system's requirements.
Related posts based on tags, category, and projects
JWT lets your server verify who a user is without storing anything in a database. This post walks through what a JWT is, how the login flow works, and how to protect routes using tokens in Node.js.
A deep dive into a deliberately flawed JWT auth system, designed as a teaching exercise to surface the security holes, race conditions, and missing edge cases that survive a first-pass auth design.
Middleware is code that runs between a request arriving and a response being sent. In Express, every middleware function in the chain gets a chance to inspect, modify, or stop a request. This post covers what middleware is, how `next()` controls the flow, and where it gets used in real applications.
Node.js runs on a single thread, yet it handles thousands of operations at once. Understanding how callbacks and promises make that possible is one of the most important milestones in becoming a confident Node.js developer.