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 serverSet-Cookie: sid=a8f3k2; HttpOnly; Secure; SameSite=Lax; Max-Age=86400; Path=/# Every later request from the browserCookie: sid=a8f3k2
Cookie Attributes
| Attribute | What it does | Protects against |
|---|---|---|
HttpOnly | JavaScript can't read the cookie (document.cookie) | Token theft through XSS |
Secure | Sent only over HTTPS | Theft on insecure networks |
SameSite=Strict | Never sent on requests from other sites | CSRF |
SameSite=Lax | Sent from other sites only on top-level GET navigation (clicking a link). The browser default | Most CSRF |
SameSite=None | Sent on all cross-site requests. Requires Secure | Nothing. Use only when needed (frontend and API on different sites) |
Max-Age / Expires | When the cookie expires. Without it, it's deleted when the browser closes | — |
Path | Sent only for URLs under this path | Limits exposure |
Domain | Which 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
- The user logs in.
- The server creates a session in a store (Redis, a database) with the user's ID, and creates a random session ID.
- The server sends the session ID in an
HttpOnlycookie. - On every request, the browser sends the cookie, and the server looks up the session.
- 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 proxyapp.use(session({store: new RedisStore({ client: redisClient }), // don't use the default memory store in productionsecret: process.env.SESSION_SECRET,resave: false,saveUninitialized: false,cookie: {httpOnly: true,secure: true,sameSite: "lax",maxAge: 24 * 60 * 60 * 1000,},}),);// Loginapp.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 fixationreq.session.regenerate((err) => {if (err) return next(err);req.session.userId = user.id;res.json({ message: "Logged in" });});});// Protect routesfunction requireLogin(req, res, next) {if (!req.session.userId)return res.status(401).json({ message: "Login required" });next();}// Logoutapp.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)
| Part | Contains |
|---|---|
| Header | The signing algorithm |
| Payload | Claims: sub (user ID), exp (expiry), iat (issued at), and your own data like role |
| Signature | Proves 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");// Createconst token = jwt.sign({ sub: "1", role: "user" }, process.env.JWT_SECRET, {expiresIn: "15m",});// Verify: throws if the signature is wrong or the token has expiredconst payload = jwt.verify(token, process.env.JWT_SECRET, {algorithms: ["HS256"], // only accept the algorithm you expect});
| Algorithm | How it works | Use when |
|---|---|---|
| HS256 | One shared secret signs and verifies | One server (or services you trust with the secret) |
| RS256 / ES256 | A private key signs, a public key verifies | Many services verify tokens, but only the auth server can create them |
Where to Store the Token in the Browser
| Storage | Risk | Notes |
|---|---|---|
localStorage | XSS: any injected script can read and steal the token | Easy to use, but risky |
| JavaScript memory (a variable) | Lost on page refresh | Safe from storage theft. Good for access tokens |
HttpOnly cookie | CSRF, because the browser sends it automatically | JavaScript 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
| Sessions | JWT | |
|---|---|---|
| Where the data lives | On the server (session store) | Inside the token |
| Each request | Looks up the session | Checks the signature. No lookup |
| Logout / revoke | Instant: delete the session | Hard: the token stays valid until it expires |
| Scaling | Every server needs the shared store (Redis) | Any server with the key can verify it |
| Size | Small cookie (just an ID) | Larger (carries data) |
| Good fit | Traditional web apps, same-site frontend | Mobile 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
| Token | Lifetime | Sent to | Stored |
|---|---|---|---|
| Access token (JWT) | Short: 5 to 15 minutes | Every API request, in the Authorization header | In memory |
| Refresh token | Long: 7 to 30 days | Only the refresh endpoint | HttpOnly 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
- Login: the server returns an access token in the response body and sets a refresh token cookie.
- API requests: the client sends
Authorization: Bearer <access token>. - Access token expires: the API returns
401. - 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. - Retry the original request with the new access token.
- 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 daysconst 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 hashexpiresAt: new Date(Date.now() + REFRESH_MAX_AGE),revoked: false,});res.cookie("refreshToken", token, {...refreshCookieOptions,maxAge: REFRESH_MAX_AGE,});}// Loginrouter.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) });});// Refreshrouter.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 stolenawait 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 }); // rotationconst user = await db.users.findById(stored.userId);await issueRefreshToken(res, user.id);res.json({ accessToken: createAccessToken(user) });});// Logoutrouter.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 setres.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 memoryasync 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
| Do | Why |
|---|---|
| Hash passwords with bcrypt or argon2 | Never store plain passwords |
Use HTTPS and Secure cookies | Tokens can't be read on the network |
Keep tokens in HttpOnly cookies or memory, not localStorage | Protects against XSS theft |
Set SameSite on cookies | Protects against CSRF |
| Keep access tokens short-lived | Limits damage if stolen |
| Rotate refresh tokens and detect reuse | Catches stolen refresh tokens |
| Store refresh tokens hashed in the database | A database leak doesn't expose usable tokens |
Pass algorithms to jwt.verify | Blocks algorithm-switching attacks |
| Keep secrets in environment variables | Never commit them to code |
| Regenerate the session ID on login | Prevents session fixation |
| Use the same message for wrong email and wrong password | Doesn'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, andSameSite. - 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
HttpOnlycookie, 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.