Cookies vs JWT — Which Authentication Method Should You Use?

SeriesTopicLevelRead Time
Web & HTTP Foundations — Part 4 of 12Authentication — Cookies vs JWTBeginner to Intermediate~17 minutes
📌  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.

StepWhat happens
1. LoginUser sends username and password to POST /auth/login
2. VerifyServer checks credentials against the database
3. Create sessionServer creates a session record in the session store (DB or Redis)
4. Set cookieServer sends Set-Cookie: sessionId=abc123 in the response
5. Browser storesBrowser stores the cookie and sends it automatically on every future request
6. AuthenticateServer receives sessionId cookie, looks it up in session store, gets user data
7. AuthoriseServer 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 ------- |                              |

Node JS Full Implementation Cookies

const express     = require('express');
const session     = require('express-session');
const RedisStore  = require('connect-redis').default;
const redis       = require('redis');
const bcrypt      = require('bcrypt');
 
const app = express();
app.use(express.json());
 
// ── Session store setup (Redis) ─────────────────────────────
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
redisClient.connect();
 
app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,   // long random string
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,       // JS cannot read the cookie
        secure: true,         // HTTPS only
        sameSite: 'strict',   // no cross-site requests
        maxAge: 24 * 60 * 60 * 1000  // 24 hours in ms
    }
}));
 
// ── Login ────────────────────────────────────────────────────
app.post('/auth/login', async (req, res) => {
    const { email, password } = req.body;
 
    // find user in database
    const user = await User.findOne({ email });
    if (!user) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }
 
    // compare password with stored hash
    const isValid = await bcrypt.compare(password, user.passwordHash);
    if (!isValid) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }
 
    // create session — express-session handles storage and cookie
    req.session.userId = user._id;
    req.session.role   = user.role;
 
    return res.status(200).json({ message: 'Logged in successfully' });
});
 
// ── Authentication middleware ────────────────────────────────
function requireAuth(req, res, next) {
    if (!req.session.userId) {
        return res.status(401).json({ error: 'Not authenticated' });
    }
    next();
}
 
// ── Protected route ──────────────────────────────────────────
app.get('/api/profile', requireAuth, async (req, res) => {
    const user = await User.findById(req.session.userId);
    return res.status(200).json({ data: user });
});
 
// ── Logout ───────────────────────────────────────────────────
app.post('/auth/logout', requireAuth, (req, res) => {
    req.session.destroy(err => {
        if (err) return res.status(500).json({ error: 'Logout failed' });
        res.clearCookie('connect.sid');
        return res.status(200).json({ message: 'Logged out' });
    });
});

Part 2 — JWT (JSON Web Token) Authentication

What is a JWT?

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.

PartNameContainsExample (decoded)
xxxxxHeaderAlgorithm and token type{“alg”: “HS256”, “typ”: “JWT”}
yyyyyPayloadClaims — user data you put in the token{“userId”: “42”, “role”: “admin”, “exp”: 1711929600}
zzzzzSignatureHMAC of header+payload using secret keyHMACSHA256(base64(header)+’.’+base64(payload), secret)

JWT

# 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

StepWhat happens
1. LoginUser sends credentials to POST /auth/login
2. VerifyServer checks credentials against database
3. Create JWTServer creates a JWT with userId, role, expiry — signed with secret key
4. Return tokenServer sends the JWT in the response body (or as a cookie)
5. Client storesClient stores the JWT (localStorage, memory, or HttpOnly cookie)
6. Send on requestsClient sends JWT in Authorization: Bearer <token> header
7. VerifyServer verifies the JWT signature — NO database lookup needed
8. AuthoriseServer 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 -------|                         |

Node JS Full Implementation JWT

const express = require('express');
const jwt     = require('jsonwebtoken');
const bcrypt  = require('bcrypt');
 
const app = express();
app.use(express.json());
 
const JWT_SECRET        = process.env.JWT_SECRET;        // long random string
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; // different secret
 
// ── Login — issue access + refresh tokens ───────────────────
app.post('/auth/login', async (req, res) => {
    const { email, password } = req.body;
 
    const user = await User.findOne({ email });
    if (!user) return res.status(401).json({ error: 'Invalid credentials' });
 
    const isValid = await bcrypt.compare(password, user.passwordHash);
    if (!isValid) return res.status(401).json({ error: 'Invalid credentials' });
 
    // access token — short lived (15 minutes)
    const accessToken = jwt.sign(
        { userId: user._id, role: user.role },
        JWT_SECRET,
        { expiresIn: '15m' }
    );
 
    // refresh token — long lived (7 days)
    const refreshToken = jwt.sign(
        { userId: user._id },
        JWT_REFRESH_SECRET,
        { expiresIn: '7d' }
    );
 
    // store refresh token in database (for revocation)
    await User.findByIdAndUpdate(user._id, { refreshToken });
 
    // send refresh token as HttpOnly cookie
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
    });
 
    // send access token in response body
    return res.status(200).json({ accessToken });
});
 
// ── Authentication middleware ────────────────────────────────
function requireAuth(req, res, next) {
    const authHeader = req.headers['authorization'];
    if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'No token provided' });
    }
 
    const token = authHeader.split(' ')[1];
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        req.user = decoded;   // { userId, role, iat, exp }
        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'Token expired' });
        }
        return res.status(401).json({ error: 'Invalid token' });
    }
}
 
// ── Refresh token endpoint ───────────────────────────────────
app.post('/auth/refresh', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    if (!refreshToken) {
        return res.status(401).json({ error: 'No refresh token' });
    }
 
    try {
        const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
 
        // verify token exists in database (enables revocation)
        const user = await User.findById(decoded.userId);
        if (!user || user.refreshToken !== refreshToken) {
            return res.status(401).json({ error: 'Invalid refresh token' });
        }
 
        // issue new access token
        const accessToken = jwt.sign(
            { userId: user._id, role: user.role },
            JWT_SECRET,
            { expiresIn: '15m' }
        );
 
        return res.status(200).json({ accessToken });
    } catch (err) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }
});
 
// ── Logout ───────────────────────────────────────────────────
app.post('/auth/logout', requireAuth, async (req, res) => {
    // remove refresh token from database
    await User.findByIdAndUpdate(req.user.userId, { refreshToken: null });
 
    // clear refresh token cookie
    res.clearCookie('refreshToken');
 
    return res.status(200).json({ message: 'Logged out' });
});
 
// ── Protected route ──────────────────────────────────────────
app.get('/api/profile', requireAuth, async (req, res) => {
    const user = await User.findById(req.user.userId);
    return res.status(200).json({ data: user });
});

Part 3 — Deep Comparison — Cookies vs JWT

AspectCookie SessionsJWT
StateStateful — server stores sessionsStateless — server stores nothing
StorageSession store (Redis/DB)Token in cookie or client memory
Server lookupDB/Redis lookup on every requestNo lookup — just verify signature
ScalabilityRequires shared session store across serversWorks natively across multiple servers
Token revocationEasy — delete session from storeHard — token valid until expiry
Token sizeSmall cookie (just session ID, ~30 bytes)Larger token (~200-500 bytes)
Mobile appsAwkward — cookies not native to mobileNatural — just send in header
MicroservicesAll services need access to session storeEach service verifies independently
CSRF riskHigher — cookies sent automatically by browserLower if sent in header (not cookie)
XSS riskLower if HttpOnly cookieHigher if stored in localStorage
DebuggingEasier — check session in RedisHarder — token is opaque once signed

Part 4 — Security Deep Dive

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 storedXSS can steal it?Notes
localStorageYES — completely exposedWorst option — all JavaScript can read localStorage
sessionStorageYES — exposed to JSSame risk as localStorage, just cleared on tab close
JavaScript variableYES — if XSS runs on pageSafer than storage but still accessible
HttpOnly CookieNO — JS cannot read itBest 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 approachCSRF riskWhy
Cookie sessionHIGH if SameSite not setBrowser sends cookies automatically — attacker can forge requests
Cookie session + SameSiteLOWSameSite=Strict blocks cross-site cookie sending
JWT in Authorization headerNONEAttacker cannot set custom headers from cross-origin JS
JWT in cookieSame as cookie sessionIf 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:

SolutionHow it worksTrade-off
Short expiry + refresh tokensAccess token expires in 15min. Refresh token rotated on each use.Best balance — de facto standard approach
Token blacklist in RedisKeep a list of revoked token IDs in Redis. Check on every request.Adds one Redis lookup — partially stateful
Token version in DBStore 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.

Node JS

// Refresh token rotation — invalidate old token, issue new one
app.post('/auth/refresh', async (req, res) => {
    const incomingRefresh = req.cookies.refreshToken;
    if (!incomingRefresh) {
        return res.status(401).json({ error: 'No refresh token' });
    }
 
    try {
        const decoded = jwt.verify(incomingRefresh, JWT_REFRESH_SECRET);
        const user = await User.findById(decoded.userId);
 
        // detect refresh token reuse (possible theft)
        if (!user || user.refreshToken !== incomingRefresh) {
            // potential token theft — revoke ALL sessions for this user
            await User.findByIdAndUpdate(decoded.userId, { refreshToken: null });
            return res.status(401).json({ error: 'Token reuse detected' });
        }
 
        // generate new tokens
        const newAccessToken = jwt.sign(
            { userId: user._id, role: user.role },
            JWT_SECRET,
            { expiresIn: '15m' }
        );
 
        const newRefreshToken = jwt.sign(
            { userId: user._id },
            JWT_REFRESH_SECRET,
            { expiresIn: '7d' }
        );
 
        // ROTATE: invalidate old refresh token, store new one
        await User.findByIdAndUpdate(user._id, { refreshToken: newRefreshToken });
 
        res.cookie('refreshToken', newRefreshToken, {
            httpOnly: true, secure: true, sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });
 
        return res.status(200).json({ accessToken: newAccessToken });
 
    } catch (err) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }
});

Part 5 — When to Use Which

After all that, here’s the practical decision framework:

Your use caseRecommended approachReason
Traditional web app (server-rendered)Cookie sessionsCookies work natively with browsers. Simple, battle-tested.
REST API for web + mobileJWTMobile apps handle headers better than cookies. Stateless scales easily.
Microservices architectureJWTEach service verifies independently. No shared session store needed.
Single server, simple appCookie sessionsNo need for JWT complexity. Sessions with Redis are simpler.
API with frequent permission changesCookie sessions or JWT + blacklistNeed ability to revoke tokens immediately.
Multi-tenant SaaSJWTTenant ID in token payload. Easy to route to correct data.
Third-party API access (OAuth)JWT (access tokens)Industry standard for OAuth2 token-based auth.
Banking / financial / healthcareCookie sessions + MFAStrict revocation requirements, regulatory compliance.
💡  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:

TokenStorageExpiryPurpose
Access Token (JWT)In-memory JavaScript variable15 minutesSent in Authorization header for API calls
Refresh Token (JWT)HttpOnly + Secure cookie7 daysUsed 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

QuestionCookie SessionsJWT
Where is user data stored?Server (Redis/DB)Inside the token (client-side)
Server needs DB on auth?YES — session lookupNO — just signature verification
Easy to revoke?YES — delete sessionNO — use short expiry + refresh tokens
Works well with mobile?No — cookies are awkwardYES — send in header
CSRF protection needed?YES — use SameSiteNO if sent in header
XSS protection needed?YES — use HttpOnlyYES — store in HttpOnly cookie, not localStorage
Best for microservices?No — shared store neededYES — stateless verification
Implementation complexity?SimpleMore complex (refresh tokens, rotation)
Best use caseTraditional web appsAPIs, 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.

Leave a Comment