
쿠키(Cookie) vs 세션(Session): 상태 관리의 양대 산맥 (대규모 업데이트)
HTTP는 기억상실증 환자입니다. 서버가 클라이언트를 기억하게 만드는 두 가지 방법론의 차이와 현대의 JWT 인증 방식 비교.

HTTP는 기억상실증 환자입니다. 서버가 클라이언트를 기억하게 만드는 두 가지 방법론의 차이와 현대의 JWT 인증 방식 비교.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

4년 전, 내 첫 스타트업 프로젝트였다. 작은 뷰티 커머스 사이트를 혼자 만들고 있었다. 로그인 기능을 구현하는데 구글링해서 찾은 튜토리얼을 그대로 따라했다. JWT를 localStorage에 저장하고, 로그인 유지가 되면 성공이라고 생각했다. 베타 테스터 5명만 초대했는데, 2일 만에 한 계정이 이상하게 행동했다. 전혀 주문하지 않은 제품들이 주문되고 있었다.
알고 보니 내가 구현한 방식이 XSS 공격에 완전히 노출되어 있었다. localStorage에 저장한 JWT는 자바스크립트로 접근 가능했고, 누군가 악의적인 스크립트를 댓글란에 심어놨었다. 그때 처음으로 받아들였다. "아, 인증/인가는 단순히 '로그인이 되냐 안 되냐'의 문제가 아니구나."
그날 밤부터 쿠키, 세션, JWT, OAuth의 세계로 빠져들었고, 결국 이거였다. HTTP는 기억상실증 환자고, 우리는 환자에게 기억을 되찾아주는 의사다. 이 글은 그때 내가 삽질하며 정리했던 노트의 확장판이다.
HTTP 프로토콜은 Stateless(무상태)다. 매번 새로운 연결이고, 매번 새로운 대화다. 네이버 메인 페이지를 보고, 1초 뒤 네이버 웹툰을 열어도, 네이버 서버는 이 두 요청이 "같은 사람"인지 모른다. 마치 첫 만남처럼 "실례지만, 누구세요?"라고 묻는다.
그런데 우리는 뭘 원하는가? 한 번 로그인하면, 쇼핑몰을 돌아다니고 장바구니에 담고 결제할 때까지 "상태"가 유지되길 바란다. 페이지를 옮길 때마다 아이디와 비밀번호를 다시 입력하고 싶지 않다. 이 "상태 유지"라는 당연해 보이는 기능이, 사실은 HTTP의 본질과 정면으로 충돌하는 요구사항이었다.
이 문제를 해결하기 위해 나온 기술이 바로 쿠키(Cookie)와 세션(Session)이다.
쿠키를 처음 이해했을 때, 나는 이렇게 비유했다. "서버가 내 이마에 포스트잇을 붙여주는 거다."
Set-Cookie: user=ratia; Max-Age=3600을 써서 보낸다.Cookie: user=ratia 헤더를 붙여서 보낸다.개발자가 프론트엔드 코드에서 일일이 Authorization 헤더를 붙이는 수고를 할 필요가 없다. 브라우저가 알아서 해준다. 이게 쿠키의 가장 큰 매력이다.
sequenceDiagram
participant C as Browser
participant S as Server
C->>S: POST /login {id, pw}
S->>C: 200 OK (Set-Cookie: user=ratia)
Note right of C: 브라우저가 'user=ratia' 저장
C->>S: GET /cart (Cookie: user=ratia)
S->>C: 200 OK (Cart Data for ratia)
하지만 쿠키를 직접 써보니 문제가 명확했다.
user=ratia가 그대로 보인다. 누군가 내 컴퓨터를 잠깐만 빌려가도 쿠키를 복사해서 갈 수 있다. 심지어 user=admin으로 수정해서 보내면? 서버가 순진하게 믿어버릴 수도 있다.결국 이해했다. 쿠키는 '전송 수단'일 뿐이지, 중요한 정보를 담는 '금고'가 아니구나.
그래서 나온 개념이 세션(Session)이다. 이 비유가 정말 와닿았다. "중요한 정보는 서버 금고에 넣고, 사용자한테는 열쇠만 주는 거다."
{ id: 50, role: 'admin', email: 'ratia@example.com' })를 넣는다.JSESSIONID=a3fWxSvYh9Kp처럼 의미 없는 긴 난수다.Set-Cookie: JSESSIONID=a3fWxSvYh9Kp; HttpOnly; Secure| 방식 | 정보 저장 위치 | 클라이언트가 가진 것 | 보안 |
|---|---|---|---|
| 쿠키 방식 | 클라이언트 | 실제 정보 (user=ratia) | 취약 (조작 가능) |
| 세션 방식 | 서버 | 참조 ID (sess_123xyz) | 안전 (무의미한 난수) |
사용자는 "의미 없는 숫자"만 가지고 있으므로, 이걸 수정해봤자 서버 DB에 없는 값이다. 인증은 실패한다. 이게 세션의 핵심 강점이다.
처음으로 제대로 된 세션을 구현한 코드다. 이때 정리해본다는 마음으로 주석을 자세히 달았다.
// Express.js + 기본 Session (메모리 저장)
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'my-super-secret-key-change-this-in-production', // 세션 암호화 키
resave: false, // 변경사항 없으면 재저장 안 함
saveUninitialized: false, // 초기화되지 않은 세션은 저장 안 함
cookie: {
maxAge: 1000 * 60 * 60, // 1시간
httpOnly: true, // JS 접근 차단 (XSS 방어)
secure: false, // 개발 환경이면 false, 프로덕션은 true
sameSite: 'lax' // CSRF 방어
}
}));
// 로그인 API
app.post('/login', (req, res) => {
const { username, password } = req.body;
// (실제로는 DB에서 검증해야 함)
if (username === 'ratia' && password === 'secret') {
req.session.userId = 50; // 세션에 사용자 ID 저장
req.session.role = 'admin';
res.json({ message: '로그인 성공' });
} else {
res.status(401).json({ message: '인증 실패' });
}
});
// 보호된 API
app.get('/dashboard', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ message: '로그인 필요' });
}
res.json({
message: '대시보드 데이터',
user: { id: req.session.userId, role: req.session.role }
});
});
// 로그아웃
app.post('/logout', (req, res) => {
req.session.destroy(); // 세션 파괴
res.json({ message: '로그아웃 완료' });
});
app.listen(3000);
이 코드는 로컬 개발에서는 완벽하게 작동했다. 그런데 문제가 터진 건 서버를 여러 대로 늘렸을 때였다.
베타 서비스가 입소문을 타서 동시 접속자가 500명을 넘었다. 서버가 버티지 못해서 AWS에 EC2 인스턴스를 3대로 늘렸다. 로드 밸런서를 앞에 두고, 트래픽을 분산시켰다.
그런데 이상한 일이 벌어졌다. 사용자들이 로그인을 했는데도 "로그인이 필요합니다"라는 에러가 간헐적으로 뜬다는 신고가 들어왔다.
세션이 각 서버의 로컬 메모리에 저장되기 때문에, 서버를 여러 대 쓰면 "공유가 안 되는" 문제가 발생한다. 이 순간 받아들였다. "세션은 확장성(Scalability)의 적이구나."
해결책은 의외로 간단했다. 세션을 각 서버 메모리가 아니라, 별도의 중앙 집중식 저장소에 넣는 것이다. 그 저장소가 바로 Redis다.
[사용자]
↓
[Load Balancer]
↓
[Web Server A] ←→ [Redis Cluster] ←→ [Web Server B]
[Web Server C] ↗
이제 모든 웹 서버가 "Redis야, 이 세션 ID 있어?"라고 물어본다. Redis는 모든 서버가 공유하는 "중앙 금고"가 되는 것이다.
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default; // v7.0 이상
const { createClient } = require('redis');
const app = express();
// Redis 클라이언트 생성
const redisClient = createClient({
host: 'localhost',
port: 6379,
// 프로덕션에서는 password, TLS 등 설정 필수
});
redisClient.connect().catch(console.error);
// Redis에 세션 저장
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:', // Redis 키 앞에 붙을 접두사
ttl: 3600 // 1시간 (초 단위)
}),
secret: 'production-secret-key-use-env-variable',
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS 전용
httpOnly: true, // XSS 방어
maxAge: 1000 * 60 * 60 // 1시간
}
}));
// 이후 로그인/로그아웃 로직은 동일
app.post('/login', (req, res) => {
req.session.userId = 50;
res.json({ message: '로그인 성공' });
});
app.listen(3000);
이 코드를 배포하고 나서, 서버를 10대로 늘려도 로그인이 유지됐다. 이때 처음으로 "확장 가능한 인증 시스템"을 만들었다는 걸 이해했다.
세션을 제대로 이해한 뒤, 다음 단계는 보안이었다. 쿠키 하나 잘못 설정하면 사이트 전체가 털릴 수 있다는 걸 알게 됐다.
cookie: {
httpOnly: true // JavaScript document.cookie로 접근 불가
}
이 옵션을 켜면, 해커가 악성 스크립트를 심어도 document.cookie로 쿠키를 읽을 수 없다. XSS 공격의 90%를 막는 가장 중요한 옵션이다.
내가 처음 localStorage에 JWT를 저장했을 때 터졌던 이유가 바로 이거다. localStorage는 JS로 접근 가능하니까, 악성 스크립트가 토큰을 훔쳐갔던 것이다.
cookie: {
secure: true // HTTPS 연결에서만 쿠키 전송
}
HTTP(비암호화)로는 쿠키가 전송되지 않는다. 카페 공용 와이파이 같은 곳에서 패킷을 스니핑해도, 암호화된 HTTPS 안에 쿠키가 숨어있어서 볼 수 없다.
cookie: {
sameSite: 'strict' // 또는 'lax', 'none'
}
Secure와 함께 써야 함. (유튜브 임베드, 페이스북 좋아요 버튼 같은 서드파티 위젯이 이걸 씀)cookie: {
httpOnly: true, // XSS 방어
secure: true, // 네트워크 스니핑 방어
sameSite: 'lax', // CSRF 방어 + UX 균형
maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
domain: '.example.com', // 서브도메인 공유
path: '/' // 모든 경로에서 유효
}
이 설정만 제대로 해도, 웬만한 쿠키 관련 보안 문제는 막을 수 있다.
이론으로만 듣던 공격을 실제로 재현해보니, 진짜로 무섭더라.
bank.com)에 접속해서 유효한 세션 ID를 받는다. SID=attacker123http://bank.com/login?JSESSIONID=attacker123"SID=attacker123를 피해자의 세션으로 승격시킨다.SID=attacker123를 알고 있으므로, 피해자의 계정으로 접속한다.로그인 성공 시, 기존 세션 ID를 버리고 새로운 ID를 재발급해야 한다.
app.post('/login', (req, res) => {
// 로그인 검증 후
const oldSessionId = req.sessionID;
req.session.regenerate((err) => { // 새 세션 ID 발급
if (err) return res.status(500).send('세션 재생성 실패');
req.session.userId = 50; // 새 세션에 데이터 저장
req.session.role = 'admin';
console.log(`Old: ${oldSessionId}, New: ${req.sessionID}`);
res.json({ message: '로그인 성공' });
});
});
대부분의 프레임워크(Spring Security, Django, Rails)는 이걸 기본으로 해준다. 하지만 직접 구현할 때는 반드시 챙겨야 한다.
세션 방식이 완벽해 보였지만, 치명적인 단점이 하나 있었다. 서버가 상태(State)를 가져야 한다는 것.
그래서 나온 게 JWT (JSON Web Token)다. 핵심 아이디어는 이거다. "서버가 정보를 저장하지 말고, 정보 자체를 암호화해서 클라이언트에게 주자."
JWT는 Header.Payload.Signature 3부분으로 나뉜다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // Header (Base64)
.
eyJ1c2VySWQiOjUwLCJyb2xlIjoiYWRtaW4ifQ // Payload (Base64)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature (HMAC-SHA256)
{"alg": "HS256", "typ": "JWT"}){"userId": 50, "role": "admin", "exp": 1735689600})HMACSHA256(base64(Header) + "." + base64(Payload), SECRET_KEY)서버는 DB를 조회하지 않는다. 대신:
DB 조회가 없으니 빠르다. Redis도 필요 없다. 서버가 Stateless해진다.
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'super-secret-key-change-in-production';
// 로그인 API: JWT 발급
app.post('/login', (req, res) => {
// 로그인 검증 후
const token = jwt.sign(
{ userId: 50, role: 'admin' }, // Payload
SECRET_KEY,
{ expiresIn: '1h' } // 1시간 후 만료
);
res.json({ accessToken: token });
});
// 보호된 API: JWT 검증
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) return res.sendStatus(401);
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403); // 토큰 만료 or 변조
req.user = user; // { userId: 50, role: 'admin' }
next();
});
}
app.get('/dashboard', authenticateToken, (req, res) => {
res.json({ message: '대시보드 데이터', user: req.user });
});
JWT를 쓰면서 깨달은 가장 큰 문제점이 있었다. 한 번 발급된 토큰은 만료 시간이 올 때까지 유효하다. 서버가 "취소"할 방법이 없다.
결국 "완전한 Stateless"는 불가능하다는 걸 받아들였다. 타협안은 이거다.
const blacklist = new Set(); // 실제로는 Redis 사용
// 로그아웃 API: JWT를 블랙리스트에 추가
app.post('/logout', authenticateToken, (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
blacklist.add(token); // Redis에 저장
res.json({ message: '로그아웃 완료' });
});
// 검증 미들웨어: 블랙리스트 확인 추가
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (blacklist.has(token)) { // Redis 조회
return res.status(403).json({ message: '로그아웃된 토큰' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
어차피 Redis를 쓰는 순간, "Stateless의 이점"은 반감된다. 하지만 세션보다는 Redis 조회 빈도가 낮다. (로그아웃한 토큰만 저장하면 되니까)
실제로 가장 많이 쓰이는 패턴이 이거다. JWT의 장점(빠른 검증)과 세션의 장점(강제 취소 가능)을 결합한 하이브리드 방식이다.
HttpOnly 쿠키에 저장. DB나 Redis에 저장해서 검증 가능.sequenceDiagram
participant User as 사용자
participant App as 프론트엔드
participant Server as 백엔드
User->>App: 로그인
App->>Server: POST /login
Server->>App: Set-Cookie: RefreshToken (HttpOnly)<br/>JSON: { accessToken }
Note right of App: AccessToken을 메모리 변수에 저장
App->>Server: GET /api/data (Auth: Bearer AccessToken)
Server->>App: 200 OK (데이터)
Note right of App: 15분 후 AccessToken 만료
App->>Server: GET /api/data (Auth: Bearer ExpiredToken)
Server->>App: 401 Unauthorized (Token Expired)
Note right of App: 자동으로 갱신 시도
App->>Server: POST /refresh (Cookie: RefreshToken)
Server->>App: 200 OK (JSON: { newAccessToken })
App->>Server: GET /api/data (Auth: Bearer NewAccessToken)
Server->>App: 200 OK (데이터)
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const ACCESS_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';
const refreshTokens = new Map(); // 실제로는 Redis
// 로그인: 두 개의 토큰 발급
app.post('/login', (req, res) => {
const userId = 50; // 로그인 검증 후
const accessToken = jwt.sign(
{ userId, role: 'admin' },
ACCESS_SECRET,
{ expiresIn: '15m' } // 15분
);
const refreshToken = jwt.sign(
{ userId, tokenId: uuidv4() },
REFRESH_SECRET,
{ expiresIn: '7d' } // 7일
);
// Refresh Token을 DB에 저장
refreshTokens.set(refreshToken, { userId, createdAt: Date.now() });
// Refresh Token은 쿠키로 (HttpOnly, Secure)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
});
// Access Token은 JSON으로
res.json({ accessToken });
});
// Access Token 갱신
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
if (!refreshTokens.has(refreshToken)) return res.sendStatus(403);
jwt.verify(refreshToken, REFRESH_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
const newAccessToken = jwt.sign(
{ userId: user.userId, role: 'admin' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
// 로그아웃: Refresh Token 무효화
app.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
refreshTokens.delete(refreshToken); // Redis에서 삭제
res.clearCookie('refreshToken');
res.json({ message: '로그아웃 완료' });
});
HttpOnly 쿠키에 있어서 XSS로 훔칠 수 없다.결국 이거였다. "은총알(Silver Bullet)은 없다. 상황에 맞는 조합이 최선이다."
| 상황 | 추천 기술 | 이유 |
|---|---|---|
| 관리자 페이지 / 사내 시스템 | Session (+ Redis) | 보안 최우선. 강제 로그아웃/중복 로그인 방지 필요. 사용자 수 적음. |
| 대규모 B2C 서비스 (쿠팡, 넷플릭스) | JWT (Refresh + Access) | 서버 비용 절감. 모바일 앱과 호환성 좋음. |
| 은행 / 금융권 | Session (+ IP/디바이스 검증) | 절대적 보안 요구. 이상 행동 감지 시 즉시 차단. |
| 마이크로서비스 (MSA) | JWT | 서비스 간 통신 시 DB 조회 없이 인증 가능. Gateway에서 검증. |
| OAuth 제공자 (구글, 페이스북) | JWT (+ Opaque Refresh Token) | 표준 스펙(RFC 6749). 서드파티 앱과 연동 용이. |
내가 내린 결론은 이거다. "초기 MVP는 Session. 트래픽이 늘면 JWT 전환. 단, Refresh Token은 반드시 서버에 저장."
쿠키는 전송 수단, 세션은 저장 방식: 쿠키와 세션은 대립 관계가 아니라 협력 관계다. 세션 ID를 쿠키에 담아 보내는 게 전통적인 방식이다.
Stateless는 이상향이지, 현실이 아니다: JWT가 완전히 Stateless하려면 강제 로그아웃을 포기해야 한다. 실제로는 Refresh Token을 DB에 저장하는 하이브리드 방식이 답이다.
보안은 옵션이 아니라 필수: HttpOnly, Secure, SameSite를 빼먹으면 언젠가 해킹당한다. 나는 4년 전에 이미 당했다. 여러분은 지금 배워서 다행이다.
이 글이 누군가의 삽질을 줄여줬으면 좋겠다. 나는 이 내용을 정리하는 데 3년이 걸렸다. 여러분은 30분이면 충분하다.
Four years ago, I was building my first startup project alone—a small beauty e-commerce site. When implementing the login feature, I blindly followed a tutorial I found on Google. Store JWT in localStorage, maintain login state, done. Success, right?
I invited just 5 beta testers. Within 2 days, one account started behaving strangely. Orders appeared for products the user never purchased.
Turns out, my implementation was wide open to XSS attacks. The JWT stored in localStorage was accessible via JavaScript. Someone had planted a malicious script in the comment section. That night, I realized: "Authentication isn't just about making login work—it's about making it secure."
That sleepless night sent me down the rabbit hole of Cookies, Sessions, JWT, and OAuth. Eventually, I understood the core truth: HTTP has amnesia, and we're the doctors trying to restore its memory. This article is the expanded version of the notes I frantically compiled during those days.
HTTP is Stateless. Every request is a new conversation. You load Naver's homepage, then open Naver Webtoon 1 second later—Naver's server has no idea these two requests came from the same person. It asks every time: "Excuse me, who are you?"
But what do users expect? Log in once, browse the shopping mall, add items to cart, checkout—all while maintaining "state." No one wants to re-enter username and password on every page transition. This seemingly basic requirement of "state persistence" directly contradicts HTTP's fundamental nature.
The technologies invented to solve this problem are Cookies and Sessions.
When I first understood Cookies, I came up with this metaphor: "The server sticks a Post-It note on my forehead."
Set-Cookie: user=ratia; Max-Age=3600 in the header.Cookie: user=ratia in the header.Developers don't need to manually attach Authorization headers in frontend code. The browser handles it automatically. That's the Cookie's biggest charm.
sequenceDiagram
participant C as Browser
participant S as Server
C->>S: POST /login {id, pw}
S->>C: 200 OK (Set-Cookie: user=ratia)
Note right of C: Browser saves 'user=ratia'
C->>S: GET /cart (Cookie: user=ratia)
S->>C: 200 OK (Cart Data for ratia)
But after using Cookies directly, the problems became clear:
user=ratia in plain text. Someone with brief access to my computer can copy the cookie. Worse, they could modify it to user=admin and send that—if the server naively trusts it, you're compromised.I finally understood: Cookies are a transport mechanism, not a vault for sensitive data.
That's where Sessions come in. This metaphor really clicked for me: "Store the important stuff in a server vault. Give users just the key."
{ id: 50, role: 'admin', email: 'ratia@example.com' }.JSESSIONID=a3fWxSvYh9Kp—a meaningless long random string.Set-Cookie: JSESSIONID=a3fWxSvYh9Kp; HttpOnly; Secure| Approach | Information Location | Client Holds | Security |
|---|---|---|---|
| Cookie Approach | Client | Actual data (user=ratia) | Vulnerable (tamperable) |
| Session Approach | Server | Reference ID (sess_123xyz) | Secure (meaningless random) |
Users only possess a "meaningless number." Even if they modify it, it won't match anything in the server's database. Authentication fails. That's the Session's core strength.
Here's the first proper session implementation I wrote. I added detailed comments as I was learning:
// Express.js + Basic Session (Memory Storage)
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'my-super-secret-key-change-this-in-production', // Session encryption key
resave: false, // Don't resave if no changes
saveUninitialized: false, // Don't save uninitialized sessions
cookie: {
maxAge: 1000 * 60 * 60, // 1 hour
httpOnly: true, // Block JS access (XSS defense)
secure: false, // false for dev, true for production
sameSite: 'lax' // CSRF defense
}
}));
// Login API
app.post('/login', (req, res) => {
const { username, password } = req.body;
// (In reality, verify against DB)
if (username === 'ratia' && password === 'secret') {
req.session.userId = 50; // Store user ID in session
req.session.role = 'admin';
res.json({ message: 'Login successful' });
} else {
res.status(401).json({ message: 'Authentication failed' });
}
});
// Protected API
app.get('/dashboard', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ message: 'Login required' });
}
res.json({
message: 'Dashboard data',
user: { id: req.session.userId, role: req.session.role }
});
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy(); // Destroy session
res.json({ message: 'Logout complete' });
});
app.listen(3000);
This code worked perfectly in local development. But the problem exploded when I scaled to multiple servers.
The beta service went viral. Concurrent users exceeded 500. The server couldn't handle it, so I spun up 3 EC2 instances on AWS. Load balancer in front, traffic distributed.
Then something strange happened. Users who logged in started intermittently seeing "Login required" errors.
Sessions stored in each server's local memory mean they're not shared across servers. In that moment, I realized: "Sessions are the enemy of scalability."
The solution was surprisingly simple: Store sessions in a separate centralized store instead of each server's memory. That store is Redis.
[Users]
↓
[Load Balancer]
↓
[Web Server A] ←→ [Redis Cluster] ←→ [Web Server B]
[Web Server C] ↗
Now all web servers ask: "Hey Redis, do you have this Session ID?" Redis becomes the "central vault" shared by all servers.
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default; // v7.0+
const { createClient } = require('redis');
const app = express();
// Create Redis client
const redisClient = createClient({
host: 'localhost',
port: 6379,
// Production: add password, TLS, etc.
});
redisClient.connect().catch(console.error);
// Store sessions in Redis
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:', // Prefix for Redis keys
ttl: 3600 // 1 hour (in seconds)
}),
secret: 'production-secret-key-use-env-variable',
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // XSS defense
maxAge: 1000 * 60 * 60 // 1 hour
}
}));
// Login/logout logic remains the same
app.post('/login', (req, res) => {
req.session.userId = 50;
res.json({ message: 'Login successful' });
});
app.listen(3000);
After deploying this, I scaled to 10 servers and login state persisted. That's when I first understood what a "scalable authentication system" meant.
After properly understanding sessions, the next step was security. One misconfigured cookie can compromise your entire site.
cookie: {
httpOnly: true // Inaccessible via JavaScript document.cookie
}
Enable this, and even if a hacker plants malicious scripts, they can't read cookies via document.cookie. This single option blocks 90% of XSS attacks.
This is why my first localStorage JWT implementation failed. localStorage is JS-accessible, so the malicious script stole the token.
cookie: {
secure: true // Cookie only sent over HTTPS
}
Cookies won't transmit over HTTP (unencrypted). Even if someone packet-sniffs public cafe WiFi, the cookie is hidden inside encrypted HTTPS.
cookie: {
sameSite: 'strict' // or 'lax', 'none'
}
Secure. (Used by third-party widgets like YouTube embeds, Facebook Like buttons)cookie: {
httpOnly: true, // XSS defense
secure: true, // Network sniffing defense
sameSite: 'lax', // CSRF defense + UX balance
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
domain: '.example.com', // Share across subdomains
path: '/' // Valid for all paths
}
Get this configuration right, and you'll prevent most cookie-related security issues.
I recreated an attack I'd only heard about in theory. Genuinely scary.
bank.com) and receives valid Session ID: SID=attacker123http://bank.com/login?JSESSIONID=attacker123"SID=attacker123 with victim's session.SID=attacker123, so they access victim's account.Upon successful login, discard the old Session ID and issue a new one.
app.post('/login', (req, res) => {
// After login verification
const oldSessionId = req.sessionID;
req.session.regenerate((err) => { // Issue new Session ID
if (err) return res.status(500).send('Session regeneration failed');
req.session.userId = 50; // Store data in new session
req.session.role = 'admin';
console.log(`Old: ${oldSessionId}, New: ${req.sessionID}`);
res.json({ message: 'Login successful' });
});
});
Most frameworks (Spring Security, Django, Rails) do this by default. But if you're implementing it yourself, this is mandatory.
Sessions seemed perfect, but they had one fatal flaw: Servers must maintain state.
Enter JWT (JSON Web Token). The core idea: "Don't store information on the server. Encrypt the information itself and give it to the client."
JWT has 3 parts: Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // Header (Base64)
.
eyJ1c2VySWQiOjUwLCJyb2xlIjoiYWRtaW4ifQ // Payload (Base64)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature (HMAC-SHA256)
{"alg": "HS256", "typ": "JWT"}){"userId": 50, "role": "admin", "exp": 1735689600})HMACSHA256(base64(Header) + "." + base64(Payload), SECRET_KEY)Server doesn't query a database. Instead:
No DB lookup, so it's fast. No Redis needed. Server becomes Stateless.
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'super-secret-key-change-in-production';
// Login API: Issue JWT
app.post('/login', (req, res) => {
// After login verification
const token = jwt.sign(
{ userId: 50, role: 'admin' }, // Payload
SECRET_KEY,
{ expiresIn: '1h' } // Expires in 1 hour
);
res.json({ accessToken: token });
});
// Protected API: Verify JWT
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) return res.sendStatus(401);
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403); // Token expired or tampered
req.user = user; // { userId: 50, role: 'admin' }
next();
});
}
app.get('/dashboard', authenticateToken, (req, res) => {
res.json({ message: 'Dashboard data', user: req.user });
});
The biggest problem I discovered with JWT: Once issued, a token remains valid until expiration. The server has no way to "revoke" it.
I realized "complete Stateless" is impossible. The compromise:
const blacklist = new Set(); // Use Redis in production
// Logout API: Add JWT to blacklist
app.post('/logout', authenticateToken, (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
blacklist.add(token); // Store in Redis
res.json({ message: 'Logout complete' });
});
// Verification middleware: Check blacklist
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (blacklist.has(token)) { // Query Redis
return res.status(403).json({ message: 'Logged out token' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
Once you use Redis, the "Stateless advantage" diminishes. But you query Redis less frequently than with sessions (only storing logged-out tokens).
The most widely used pattern in production environments. A hybrid approach combining JWT's strength (fast verification) with Session's strength (revocable).
HttpOnly cookie. Stored in DB or Redis for verification.sequenceDiagram
participant User as User
participant App as Frontend
participant Server as Backend
User->>App: Login
App->>Server: POST /login
Server->>App: Set-Cookie: RefreshToken (HttpOnly)<br/>JSON: { accessToken }
Note right of App: Store AccessToken in memory variable
App->>Server: GET /api/data (Auth: Bearer AccessToken)
Server->>App: 200 OK (Data)
Note right of App: AccessToken expires after 15 min
App->>Server: GET /api/data (Auth: Bearer ExpiredToken)
Server->>App: 401 Unauthorized (Token Expired)
Note right of App: Automatically attempt refresh
App->>Server: POST /refresh (Cookie: RefreshToken)
Server->>App: 200 OK (JSON: { newAccessToken })
App->>Server: GET /api/data (Auth: Bearer NewAccessToken)
Server->>App: 200 OK (Data)
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const ACCESS_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';
const refreshTokens = new Map(); // Use Redis in production
// Login: Issue two tokens
app.post('/login', (req, res) => {
const userId = 50; // After login verification
const accessToken = jwt.sign(
{ userId, role: 'admin' },
ACCESS_SECRET,
{ expiresIn: '15m' } // 15 minutes
);
const refreshToken = jwt.sign(
{ userId, tokenId: uuidv4() },
REFRESH_SECRET,
{ expiresIn: '7d' } // 7 days
);
// Store Refresh Token in DB
refreshTokens.set(refreshToken, { userId, createdAt: Date.now() });
// Refresh Token as cookie (HttpOnly, Secure)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Access Token as JSON
res.json({ accessToken });
});
// Refresh Access Token
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
if (!refreshTokens.has(refreshToken)) return res.sendStatus(403);
jwt.verify(refreshToken, REFRESH_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
const newAccessToken = jwt.sign(
{ userId: user.userId, role: 'admin' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
// Logout: Invalidate Refresh Token
app.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
refreshTokens.delete(refreshToken); // Delete from Redis
res.clearCookie('refreshToken');
res.json({ message: 'Logout complete' });
});
HttpOnly cookie can't be stolen via XSS.I realized: "There's no silver bullet. Context-appropriate combinations are optimal."
| Scenario | Recommended | Reasoning |
|---|---|---|
| Admin Panel / Internal Systems | Session (+ Redis) | Security priority. Need forced logout/prevent duplicate login. Small user count. |
| Large-Scale B2C (Amazon, Netflix) | JWT (Refresh + Access) | Server cost reduction. Mobile app compatibility. |
| Banking / Financial | Session (+ IP/Device validation) | Absolute security requirement. Immediate blocking on suspicious activity. |
| Microservices (MSA) | JWT | Service-to-service communication without DB lookup. Gateway verification. |
| OAuth Provider (Google, Facebook) | JWT (+ Opaque Refresh Token) | Standard spec (RFC 6749). Third-party app integration. |
My conclusion: "Start with Session for MVP. Switch to JWT as traffic grows. But always store Refresh Tokens server-side."