📌 This is Part 4 of the Web & HTTP Foundations series. Part 3 covered HTTP Headers in depth — specifically the Authorization header and Set-Cookie header which are central to this post. Read that first if you haven’t.
Every web application needs to answer the same question on every request: who is this person, and are they allowed to do this?
Authentication is how you answer the first part. And there are two dominant approaches: Cookie-based sessions and JWT (JSON Web Tokens).
The internet is full of hot takes on this topic. ‘JWT is better’, ‘cookies are more secure’, ‘never store JWT in localStorage’. Most of these are half-truths without context.
In this post we go through both approaches from scratch — how they work, what the actual security trade-offs are, when each one is the right choice, and full Node.js implementation code for both. By the end, you will be able to make this decision confidently for any project.
The Problem — Why Authentication Is Hard
HTTP is stateless. Every request is completely independent. The server has no memory of previous requests.
This creates a fundamental problem for web applications. When a user logs in, the server verifies their credentials. But on the very next request — loading their profile, fetching their orders, doing anything — the server has already forgotten who they are.
🤔 How does the server know who is making the request, without asking for a username and password on every single API call?
This is the authentication problem. Both cookies and JWT are solutions to it — but they solve it differently.
Part 1 — Cookie-Based Session Authentication
How It Works
Cookie-based authentication works by creating a server-side session and giving the client a reference to it — a session ID stored in a cookie.
Step
What happens
1. Login
User sends username and password to POST /auth/login
2. Verify
Server checks credentials against the database
3. Create session
Server creates a session record in the session store (DB or Redis)
4. Set cookie
Server sends Set-Cookie: sessionId=abc123 in the response
5. Browser stores
Browser stores the cookie and sends it automatically on every future request
6. Authenticate
Server receives sessionId cookie, looks it up in session store, gets user data
7. Authorise
Server checks if the user has permission for the requested action
Flow
REQUEST FLOW (Cookie-based):
Browser Server Session Store (Redis)
| | |
|-- POST /login ----------->| |
| {email, password} | |
| |-- store session ------------>|
| | {userId:42, role:"admin"} |
|<-- 200 + Set-Cookie -----| |
| sessionId=abc123 | |
| | |
|-- GET /profile ----------> | |
| Cookie: sessionId=abc123|-- lookup sessionId -------->|
| |<-- {userId:42, role:admin} --|
|<-- 200 + user data ------- | |
A JWT is a self-contained token that carries the user’s data inside it — signed by the server. The server does not store anything. The token itself is the proof of identity.
JWT Structure — Three Parts
A JWT looks like this: xxxxx.yyyyy.zzzzz — three Base64URL-encoded sections separated by dots.
# A real JWT token (split across lines for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTg0MzIwMCwiZXhwIjoxNzExOTI5NjAwfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
# Decoded Header:
{
"alg": "HS256",
"typ": "JWT"
}
# Decoded Payload:
{
"userId": "42",
"role": "admin",
"iat": 1711843200, // issued at (Unix timestamp)
"exp": 1711929600 // expires at (Unix timestamp)
}
# Signature: HMAC-SHA256 of the above using your secret key
# Server verifies: recalculate signature and compare — if they match, token is valid
🔑 The payload is Base64 encoded — NOT encrypted. Anyone can decode it and read the contents. NEVER put sensitive data (passwords, card numbers, private info) in a JWT payload. The signature only proves the token was issued by you — it does not hide the data.
How JWT Authentication Works
Step
What happens
1. Login
User sends credentials to POST /auth/login
2. Verify
Server checks credentials against database
3. Create JWT
Server creates a JWT with userId, role, expiry — signed with secret key
4. Return token
Server sends the JWT in the response body (or as a cookie)
5. Client stores
Client stores the JWT (localStorage, memory, or HttpOnly cookie)
6. Send on requests
Client sends JWT in Authorization: Bearer <token> header
7. Verify
Server verifies the JWT signature — NO database lookup needed
8. Authorise
Server reads userId and role from the token payload
Flow
REQUEST FLOW (JWT-based):
Browser Server Database
| | |
|-- POST /login ----------->| |
| {email, password} |-- verify user -------->|
| |<-- user found ----------|
|<-- 200 + {token: "eyJ..."}-| |
| | (NO session stored) |
| | |
|-- GET /profile ----------> | |
| Authorization: | verify JWT signature |
| Bearer eyJ... | (NO database lookup!) |
|<-- 200 + user data -------| |
This is where most blog posts get it wrong. Let’s go through the real security considerations for both approaches.
The XSS Problem
XSS (Cross-Site Scripting) is when an attacker injects malicious JavaScript into your page. If auth tokens are accessible to JavaScript, XSS can steal them.
Where token is stored
XSS can steal it?
Notes
localStorage
YES — completely exposed
Worst option — all JavaScript can read localStorage
sessionStorage
YES — exposed to JS
Same risk as localStorage, just cleared on tab close
JavaScript variable
YES — if XSS runs on page
Safer than storage but still accessible
HttpOnly Cookie
NO — JS cannot read it
Best option — browser sends it but JS cannot access
❌ NEVER store JWT in localStorage. This is the single most common JWT security mistake. Any XSS vulnerability — in your code OR any third-party script you include — can steal all your users’ tokens. Use HttpOnly cookies.
The CSRF Problem
CSRF (Cross-Site Request Forgery) is when an attacker tricks a user’s browser into making authenticated requests to your API from a different website.
Auth approach
CSRF risk
Why
Cookie session
HIGH if SameSite not set
Browser sends cookies automatically — attacker can forge requests
Cookie session + SameSite
LOW
SameSite=Strict blocks cross-site cookie sending
JWT in Authorization header
NONE
Attacker cannot set custom headers from cross-origin JS
JWT in cookie
Same as cookie session
If stored in cookie, same CSRF risk applies
🔒 The safest JWT setup: store the access token in memory (JavaScript variable), store the refresh token in an HttpOnly+Secure+SameSite=Strict cookie. Access token cannot be stolen by CSRF (it’s in memory). Refresh token cannot be stolen by XSS (it’s HttpOnly). Both threats mitigated.
The Token Revocation Problem
This is the most important JWT limitation that many developers discover too late.
🤔 What happens when you need to log out a user immediately — for example, after a password change, account compromise, or admin ban? With sessions, you delete the session. Done. With JWT — you can’t. The token is valid until it expires.
Solutions to JWT revocation:
Solution
How it works
Trade-off
Short expiry + refresh tokens
Access token expires in 15min. Refresh token rotated on each use.
Best balance — de facto standard approach
Token blacklist in Redis
Keep a list of revoked token IDs in Redis. Check on every request.
Adds one Redis lookup — partially stateful
Token version in DB
Store tokenVersion in user record. Include in JWT. Check on request.
One DB lookup — catches compromised tokens
Short expiry only (no refresh)
Access token expires in 5-15 min. User re-logs after expiry.
Worst UX — users re-login frequently
🚀 The industry standard in 2026: access token with 15-minute expiry + refresh token with 7-day expiry stored in HttpOnly cookie. When access token expires, client silently hits /auth/refresh to get a new one. Refresh token is rotated on each use (old one invalidated). This covers 95% of use cases.
Refresh Token Rotation
Refresh token rotation means: every time a refresh token is used to get a new access token, the refresh token itself is also replaced with a new one. The old one is invalidated.
This provides an important security benefit: if an attacker steals a refresh token and uses it, the legitimate user’s next refresh attempt fails — because their token was already used. This signals a token theft.
💡 For most new projects in 2026 — REST API + mobile app + multiple servers — JWT with refresh token rotation is the right default. For simple server-rendered web apps with a single backend — cookie sessions are simpler and just as secure.
Part 6 — Common Mistakes and How to Avoid Them
JWT Mistakes
❌ Storing JWT in localStorage — exposed to all JavaScript including third-party scripts. Use HttpOnly cookie or in-memory.
❌ Using long expiry (30 days) for access tokens without refresh — if token is stolen, attacker has access for 30 days. Use 15 minutes.
❌ Putting sensitive data in JWT payload — the payload is base64 encoded, NOT encrypted. Anyone can decode it. Never put passwords, card details, or private info in JWT.
❌ Using the same secret for access and refresh tokens — if your secret leaks, attackers can forge both. Use separate secrets.
❌ Not verifying the algorithm — JWT header specifies the algorithm. Some older libraries accept ‘none’ as an algorithm (no signature). Always explicitly specify the expected algorithm in jwt.verify().
Cookie Session Mistakes
❌ Not setting HttpOnly on session cookies — JavaScript can steal them via XSS. Always set HttpOnly.
❌ Not setting SameSite — without it, the cookie is sent on cross-site requests — enabling CSRF attacks. Use SameSite=Strict for most apps.
❌ Storing sessions in memory (MemoryStore) in production — if your server restarts, all users are logged out. Worse, it leaks memory under load. Always use Redis or a database session store.
❌ Not setting a session secret — default session libraries use a weak or undefined secret. Always set a strong random SESSION_SECRET environment variable.
Part 7 — The Hybrid Approach (Best of Both)
The most secure production setup combines the best of both worlds:
Token
Storage
Expiry
Purpose
Access Token (JWT)
In-memory JavaScript variable
15 minutes
Sent in Authorization header for API calls
Refresh Token (JWT)
HttpOnly + Secure cookie
7 days
Used only to get new access tokens
This approach:
Access token in memory — XSS cannot steal it (JS variable, not storage)
Refresh token in HttpOnly cookie — XSS cannot steal it (cookie not readable by JS)
Refresh token with SameSite=Strict — CSRF cannot use it
15-minute access token — even if intercepted, short window of exposure
Refresh token rotation — detects stolen tokens
Interview Questions on Authentication
🎯 Q: What is the difference between authentication and authorisation?
Authentication is verifying WHO you are — validating credentials to confirm your identity. Authorisation is verifying WHAT you are allowed to do — checking permissions after identity is confirmed. Authentication comes first, then authorisation. A user can be authenticated (logged in) but not authorised to access a specific admin endpoint.
🎯 Q: What is the main weakness of JWT and how do you handle it?
JWT tokens cannot be invalidated before expiry — the server is stateless and has no record of issued tokens. Handle it with: (1) short access token expiry (15 minutes), (2) refresh tokens for maintaining sessions, (3) store refresh token in DB for revocation support, (4) use refresh token rotation to detect stolen tokens. For applications requiring immediate revocation (banking, security-sensitive), maintain a Redis blacklist of revoked token IDs.
🎯 Q: Where should you store JWT tokens on the client side?
Never in localStorage — exposed to XSS. The recommended approach: store the access token in a JavaScript variable (in memory). Store the refresh token in an HttpOnly + Secure + SameSite=Strict cookie. The access token in memory is lost on page refresh — the refresh token cookie silently restores the session. This prevents both XSS token theft (HttpOnly) and CSRF attacks (SameSite + access token in header).
🎯 Q: How would you implement ‘remember me’ functionality with JWT?
With the hybrid approach: when ‘remember me’ is checked, set the refresh token cookie Max-Age to 30 days instead of 7. When not checked, omit Max-Age (session cookie — expires when browser closes). The access token stays at 15-minute expiry regardless. This gives persistent login without security compromise.
🎯 Q: How do you handle auth in a microservices architecture?
JWT is the natural choice. The API Gateway validates the JWT and forwards user information (userId, role) to downstream services via headers like X-User-ID and X-User-Role. Downstream services trust these headers since they only receive traffic from the gateway. Each service does not need access to the auth service or session store. This is the standard pattern used by Netflix, Uber, and most large microservice architectures.
Final Comparison — Everything in One Table
Question
Cookie Sessions
JWT
Where is user data stored?
Server (Redis/DB)
Inside the token (client-side)
Server needs DB on auth?
YES — session lookup
NO — just signature verification
Easy to revoke?
YES — delete session
NO — use short expiry + refresh tokens
Works well with mobile?
No — cookies are awkward
YES — send in header
CSRF protection needed?
YES — use SameSite
NO if sent in header
XSS protection needed?
YES — use HttpOnly
YES — store in HttpOnly cookie, not localStorage
Best for microservices?
No — shared store needed
YES — stateless verification
Implementation complexity?
Simple
More complex (refresh tokens, rotation)
Best use case
Traditional web apps
APIs, mobile, microservices
Wrapping Up
Cookies vs JWT is not a question of which one is better — it’s a question of which one fits your architecture.
The three rules to remember:
Never store JWT in localStorage — always use HttpOnly cookies or in-memory
Always use short access token expiry (15 minutes) with refresh token rotation
Cookie sessions for simple web apps — JWT for APIs, mobile, and microservices
Both approaches, when implemented correctly, are equally secure. The mistakes happen in implementation — not in the approach itself.
📬 This is Part 4 of the Web & HTTP Foundations series on Daily Dev Notes. Next: Part 5 — HTTPS and TLS Basics — how encryption actually works. Subscribe so you don’t miss it.
Have a question about a specific implementation or security scenario? Drop it in the comments — I reply to every one.