On this page

JWT Auth, Sessions, Cookies, and Refresh Tokens

Every app needs to know who is making a request. HTTP doesn't remember anything between requests, so after a user logs in, the server needs a way to recognize them on every request after that. The two main approaches are sessions and JWTs, and both usually travel in cookies. Refresh tokens make JWTs safe to use for long logins.

Definition

  • Authentication: checking who the user is (login).
  • Authorization: checking what the user is allowed to do (permissions).
  • Cookie: a small piece of data the server asks the browser to store. The browser sends it back automatically with every request to that site.
  • Session: the server stores the login state and gives the browser only a random session ID in a cookie.
  • JWT (JSON Web Token): a signed token that contains the user's data itself. The server checks the signature instead of looking anything up.
  • Access token: a short-lived token (like 15 minutes) sent with every API request.
  • Refresh token: a long-lived token (like 7 days) used only to get a new access token.

Analogy:

  • Session: a cloakroom ticket. The ticket is just a number. Your coat (your data) stays with the cloakroom (server), and they look it up each time.
  • JWT: a stamped ID card. All your details are on the card, and the guard only checks that the stamp is real. No lookup needed, but if the card is stolen, it works until it expires.
  • Refresh token: a hotel keycard. It stops working every day, and you go to the front desk (refresh endpoint) to get it renewed. The front desk can refuse if you've checked out.

Part 1: Cookies

The server sets a cookie with the Set-Cookie header. The browser sends it back with the Cookie header.

# Response from the server
Set-Cookie: sid=a8f3k2; HttpOnly; Secure; SameSite=Lax; Max-Age=86400; Path=/
# Every later request from the browser
Cookie: sid=a8f3k2
AttributeWhat it doesProtects against
HttpOnlyJavaScript can't read the cookie (document.cookie)Token theft through XSS
SecureSent only over HTTPSTheft on insecure networks
SameSite=StrictNever sent on requests from other sitesCSRF
SameSite=LaxSent from other sites only on top-level GET navigation (clicking a link). The browser defaultMost CSRF
SameSite=NoneSent on all cross-site requests. Requires SecureNothing. Use only when needed (frontend and API on different sites)
Max-Age / ExpiresWhen the cookie expires. Without it, it's deleted when the browser closes—
PathSent only for URLs under this pathLimits exposure
DomainWhich domains receive it—
res.cookie("theme", "dark", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000, // 1 day, in milliseconds
});

Part 2: Sessions

How It Works

  1. The user logs in.
  2. The server creates a session in a store (Redis, a database) with the user's ID, and creates a random session ID.
  3. The server sends the session ID in an HttpOnly cookie.
  4. On every request, the browser sends the cookie, and the server looks up the session.
  5. On logout, the server deletes the session. It stops working immediately.
const session = require("express-session");
const { RedisStore } = require("connect-redis");
app.set("trust proxy", 1); // needed for Secure cookies behind a proxy
app.use(
session({
store: new RedisStore({ client: redisClient }), // don't use the default memory store in production
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000,
},
}),
);
// Login
app.post("/login", async (req, res, next) => {
const user = await checkCredentials(req.body.email, req.body.password);
if (!user)
return res.status(401).json({ message: "Invalid email or password" });
// New session ID on login, to prevent session fixation
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
res.json({ message: "Logged in" });
});
});
// Protect routes
function requireLogin(req, res, next) {
if (!req.session.userId)
return res.status(401).json({ message: "Login required" });
next();
}
// Logout
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.clearCookie("connect.sid");
res.status(204).end();
});
});

Part 3: JWT

Structure

A JWT has three parts, separated by dots: header.payload.signature.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MzAwMDAwMDB9.4Xk9...
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "1", "role": "user", "iat": 1729999100, "exp": 1730000000 }
Signature: HMAC-SHA256(header + "." + payload, secret)
PartContains
HeaderThe signing algorithm
PayloadClaims: sub (user ID), exp (expiry), iat (issued at), and your own data like role
SignatureProves the token was created by the server and not changed

Important: a JWT is signed, not encrypted. Anyone can decode and read the payload. Never put passwords or sensitive data in it.

Signing and Verifying

const jwt = require("jsonwebtoken");
// Create
const token = jwt.sign({ sub: "1", role: "user" }, process.env.JWT_SECRET, {
expiresIn: "15m",
});
// Verify: throws if the signature is wrong or the token has expired
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"], // only accept the algorithm you expect
});
AlgorithmHow it worksUse when
HS256One shared secret signs and verifiesOne server (or services you trust with the secret)
RS256 / ES256A private key signs, a public key verifiesMany services verify tokens, but only the auth server can create them

Where to Store the Token in the Browser

StorageRiskNotes
localStorageXSS: any injected script can read and steal the tokenEasy to use, but risky
JavaScript memory (a variable)Lost on page refreshSafe from storage theft. Good for access tokens
HttpOnly cookieCSRF, because the browser sends it automaticallyJavaScript can't read it. Use SameSite against CSRF. Good for refresh tokens

A common setup: access token in memory, refresh token in an HttpOnly, Secure, SameSite=Strict cookie.

Sessions vs JWT

SessionsJWT
Where the data livesOn the server (session store)Inside the token
Each requestLooks up the sessionChecks the signature. No lookup
Logout / revokeInstant: delete the sessionHard: the token stays valid until it expires
ScalingEvery server needs the shared store (Redis)Any server with the key can verify it
SizeSmall cookie (just an ID)Larger (carries data)
Good fitTraditional web apps, same-site frontendMobile apps, APIs, microservices

The biggest weakness of JWT is revocation. If a token is stolen, it works until it expires. The fix is to make access tokens short-lived and use refresh tokens.

Part 4: Refresh Tokens

The Idea

TokenLifetimeSent toStored
Access token (JWT)Short: 5 to 15 minutesEvery API request, in the Authorization headerIn memory
Refresh tokenLong: 7 to 30 daysOnly the refresh endpointHttpOnly cookie, and a hash of it in the database

A stolen access token is useful for only a few minutes. The refresh token is checked against the database, so it can be revoked, giving you the logout control of sessions.

The Flow

  1. Login: the server returns an access token in the response body and sets a refresh token cookie.
  2. API requests: the client sends Authorization: Bearer <access token>.
  3. Access token expires: the API returns 401.
  4. Refresh: the client calls POST /auth/refresh. The browser sends the refresh cookie automatically. The server checks it, issues a new access token and a new refresh token (rotation), and revokes the old one.
  5. Retry the original request with the new access token.
  6. Logout: the server revokes the refresh token and clears the cookie.

Refresh Token Rotation and Reuse Detection

  • Rotation: every refresh gives a new refresh token, and the old one is revoked.
  • Reuse detection: if a revoked refresh token is used again, someone has stolen a copy. The server revokes all of that user's refresh tokens, forcing a fresh login.

Implementation

const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const crypto = require("crypto");
const cookieParser = require("cookie-parser");
const router = express.Router();
router.use(cookieParser());
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const refreshCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/api/auth", // sent only to auth routes
};
function createAccessToken(user) {
return jwt.sign({ sub: String(user.id), role: user.role }, ACCESS_SECRET, {
expiresIn: "15m",
});
}
function hashToken(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
async function issueRefreshToken(res, userId) {
const token = crypto.randomBytes(32).toString("hex");
await db.refreshTokens.create({
userId,
tokenHash: hashToken(token), // store only the hash
expiresAt: new Date(Date.now() + REFRESH_MAX_AGE),
revoked: false,
});
res.cookie("refreshToken", token, {
...refreshCookieOptions,
maxAge: REFRESH_MAX_AGE,
});
}
// Login
router.post("/login", async (req, res) => {
const user = await db.users.findOne({ email: req.body.email });
const ok =
user && (await bcrypt.compare(req.body.password, user.passwordHash));
if (!ok)
return res.status(401).json({ message: "Invalid email or password" });
await issueRefreshToken(res, user.id);
res.json({ accessToken: createAccessToken(user) });
});
// Refresh
router.post("/refresh", async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ message: "No refresh token" });
const stored = await db.refreshTokens.findOne({
tokenHash: hashToken(token),
});
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ message: "Invalid refresh token" });
}
if (stored.revoked) {
// Reuse of an old token: it was likely stolen
await db.refreshTokens.updateMany(
{ userId: stored.userId },
{ revoked: true },
);
return res.status(401).json({ message: "Please log in again" });
}
await db.refreshTokens.update({ id: stored.id }, { revoked: true }); // rotation
const user = await db.users.findById(stored.userId);
await issueRefreshToken(res, user.id);
res.json({ accessToken: createAccessToken(user) });
});
// Logout
router.post("/logout", async (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
await db.refreshTokens.update(
{ tokenHash: hashToken(token) },
{ revoked: true },
);
}
res.clearCookie("refreshToken", refreshCookieOptions); // same options as when set
res.status(204).end();
});
module.exports = router;

Protecting Routes With the Access Token

function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ message: "Login required" });
}
try {
const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
algorithms: ["HS256"],
});
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
}
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ message: "Not allowed" });
}
next();
};
}
app.get("/api/profile", requireAuth, (req, res) => res.json(req.user));
app.delete("/api/users/:id", requireAuth, requireRole("admin"), deleteUser);

The Client Side

let accessToken = null; // kept in memory
async function api(url, options = {}) {
const send = () =>
fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
let res = await send();
if (res.status === 401) {
const refresh = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include", // send the refresh cookie
});
if (!refresh.ok) {
window.location.href = "/login";
return;
}
({ accessToken } = await refresh.json());
res = await send(); // retry once
}
return res;
}

Security Checklist

DoWhy
Hash passwords with bcrypt or argon2Never store plain passwords
Use HTTPS and Secure cookiesTokens can't be read on the network
Keep tokens in HttpOnly cookies or memory, not localStorageProtects against XSS theft
Set SameSite on cookiesProtects against CSRF
Keep access tokens short-livedLimits damage if stolen
Rotate refresh tokens and detect reuseCatches stolen refresh tokens
Store refresh tokens hashed in the databaseA database leak doesn't expose usable tokens
Pass algorithms to jwt.verifyBlocks algorithm-switching attacks
Keep secrets in environment variablesNever commit them to code
Regenerate the session ID on loginPrevents session fixation
Use the same message for wrong email and wrong passwordDoesn't reveal which emails exist

Summary

  • Authentication checks who you are. Authorization checks what you can do.
  • Cookies are stored by the browser and sent automatically. Use HttpOnly, Secure, and SameSite.
  • Sessions keep login state on the server. The cookie holds only a session ID. Logout is instant.
  • A JWT is header.payload.signature. It is signed, not encrypted, so never put secrets in it.
  • JWTs need no lookup, but are hard to revoke before they expire.
  • Use short-lived access tokens with long-lived refresh tokens.
  • Store the access token in memory and the refresh token in an HttpOnly cookie, with its hash in the database.
  • Rotate refresh tokens on every use, and revoke all of them if an old one is reused.
  • 401 means not logged in or expired. 403 means logged in but not allowed.

Interview Answer: How Do You Handle Authentication With JWT, Sessions, and Refresh Tokens?

With sessions, the server stores the login state in a store like Redis and gives the browser only a random session ID in an HttpOnly cookie. Each request looks up the session, and logout is instant because I just delete it. With JWT, the token itself contains the user's data and is signed, so the server only verifies the signature with no lookup. That scales well across services, but a JWT can't easily be revoked, and its payload is readable by anyone, so I never put sensitive data in it.

To get the best of both, I use short-lived access tokens, around 15 minutes, sent in the Authorization header and kept in memory, plus a long-lived refresh token in an HttpOnly, Secure, SameSite=Strict cookie. I store a hash of the refresh token in the database. When the access token expires, the client calls the refresh endpoint, and I issue a new pair and revoke the old refresh token. That's rotation. If a revoked refresh token is ever used again, it was likely stolen, so I revoke all of that user's tokens. On logout, I revoke the refresh token and clear the cookie.

For cookies, HttpOnly protects against XSS and SameSite protects against CSRF. I always hash passwords with bcrypt, use HTTPS, and return 401 for unauthenticated requests and 403 for unauthorized ones.

Next TopicCORS, Rate Limiting, and Helmet