1. 프롤로그 - 내가 만든 첫 로그인 시스템이 터진 날
4년 전, 내 첫 스타트업 프로젝트였다. 작은 뷰티 커머스 사이트를 혼자 만들고 있었다. 로그인 기능을 구현하는데 구글링해서 찾은 튜토리얼을 그대로 따라했다. JWT를 localStorage에 저장하고, 로그인 유지가 되면 성공이라고 생각했다. 베타 테스터 5명만 초대했는데, 2일 만에 한 계정이 이상하게 행동했다. 전혀 주문하지 않은 제품들이 주문되고 있었다.
알고 보니 내가 구현한 방식이 XSS 공격에 완전히 노출되어 있었다. localStorage에 저장한 JWT는 자바스크립트로 접근 가능했고, 누군가 악의적인 스크립트를 댓글란에 심어놨었다. 그때 처음으로 받아들였다. "아, 인증/인가는 단순히 '로그인이 되냐 안 되냐'의 문제가 아니구나."
그날 밤부터 쿠키, 세션, JWT, OAuth의 세계로 빠져들었고, 결국 이거였다. HTTP는 기억상실증 환자고, 우리는 환자에게 기억을 되찾아주는 의사다. 이 글은 그때 내가 삽질하며 정리했던 노트의 확장판이다.
2. 고민의 시작 - 기억상실증에 걸린 HTTP
HTTP 프로토콜은 Stateless(무상태)다. 매번 새로운 연결이고, 매번 새로운 대화다. 네이버 메인 페이지를 보고, 1초 뒤 네이버 웹툰을 열어도, 네이버 서버는 이 두 요청이 "같은 사람"인지 모른다. 마치 첫 만남처럼 "실례지만, 누구세요?"라고 묻는다.
그런데 우리는 뭘 원하는가? 한 번 로그인하면, 쇼핑몰을 돌아다니고 장바구니에 담고 결제할 때까지 "상태"가 유지되길 바란다. 페이지를 옮길 때마다 아이디와 비밀번호를 다시 입력하고 싶지 않다. 이 "상태 유지"라는 당연해 보이는 기능이, 사실은 HTTP의 본질과 정면으로 충돌하는 요구사항이었다.
이 문제를 해결하기 위해 나온 기술이 바로 쿠키(Cookie)와 세션(Session)이다.
3. 첫 번째 해결책 - 쿠키 (Cookie) - "내 이마에 붙은 포스트잇"
쿠키를 처음 이해했을 때, 나는 이렇게 비유했다. "서버가 내 이마에 포스트잇을 붙여주는 거다."
작동 원리
- 발급 단계: 내가 로그인하면 서버가 응답 헤더에
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으로 수정해서 보내면? 서버가 순진하게 믿어버릴 수도 있다. - 용량 제한: 쿠키는 4KB까지만 저장할 수 있다. 사용자 프로필 사진 URL, 권한 목록 등을 다 넣으려면 부족하다.
- 트래픽 낭비: 쿠키는 모든 요청에 자동으로 붙는다. 이미지 하나를 로딩할 때도, API 한 번 호출할 때도 쿠키가 따라간다. 쿠키가 커지면 네트워크 트래픽이 불필요하게 증가한다.
결국 이해했다. 쿠키는 '전송 수단'일 뿐이지, 중요한 정보를 담는 '금고'가 아니구나.
4. 두 번째 해결책 - 세션 (Session) - "서버 금고의 열쇠"
그래서 나온 개념이 세션(Session)이다. 이 비유가 정말 와닿았다. "중요한 정보는 서버 금고에 넣고, 사용자한테는 열쇠만 주는 거다."
작동 원리
- 금고 생성: 사용자가 로그인하면 서버는 메모리나 DB에 "금고"를 하나 만든다. 금고 안에는 실제 정보(
{ id: 50, role: 'admin', email: 'ratia@example.com' })를 넣는다. - 열쇠 발급: 이 금고를 열 수 있는 무작위 문자열 "열쇠"(Session ID)를 만든다. 예를 들면
JSESSIONID=a3fWxSvYh9Kp처럼 의미 없는 긴 난수다. - 전송: 이 열쇠를 쿠키에 담아서 클라이언트에게 보낸다.
Set-Cookie: JSESSIONID=a3fWxSvYh9Kp; HttpOnly; Secure - 검증: 클라이언트가 요청을 보낼 때 열쇠(쿠키)를 함께 보내면, 서버는 금고를 열어보고 "아, ratia님이 맞네요" 확인한다.
핵심 차이
| 방식 | 정보 저장 위치 | 클라이언트가 가진 것 | 보안 |
|---|---|---|---|
| 쿠키 방식 | 클라이언트 | 실제 정보 (user=ratia) | 취약 (조작 가능) |
| 세션 방식 | 서버 | 참조 ID (sess_123xyz) | 안전 (무의미한 난수) |
사용자는 "의미 없는 숫자"만 가지고 있으므로, 이걸 수정해봤자 서버 DB에 없는 값이다. 인증은 실패한다. 이게 세션의 핵심 강점이다.
5. 실제 첫 구현 - Express.js Session 코드
처음으로 제대로 된 세션을 구현한 코드다. 이때 정리해본다는 마음으로 주석을 자세히 달았다.
// 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);
이 코드는 로컬 개발에서는 완벽하게 작동했다. 그런데 문제가 터진 건 서버를 여러 대로 늘렸을 때였다.
6. 좌절의 순간 - 서버 확장(Scale-out) 문제
베타 서비스가 입소문을 타서 동시 접속자가 500명을 넘었다. 서버가 버티지 못해서 AWS에 EC2 인스턴스를 3대로 늘렸다. 로드 밸런서를 앞에 두고, 트래픽을 분산시켰다.
그런데 이상한 일이 벌어졌다. 사용자들이 로그인을 했는데도 "로그인이 필요합니다"라는 에러가 간헐적으로 뜬다는 신고가 들어왔다.
문제의 원인
- 사용자가 서버 A에 로그인했다. (서버 A의 메모리에 세션 생성)
- 다음 요청이 로드 밸런서를 통해 서버 B로 갔다.
- 서버 B는 자기 메모리에 해당 세션이 없다. "누구세요?"
- 로그인이 풀린다.
세션이 각 서버의 로컬 메모리에 저장되기 때문에, 서버를 여러 대 쓰면 "공유가 안 되는" 문제가 발생한다. 이 순간 받아들였다. "세션은 확장성(Scalability)의 적이구나."
7. 전환점(Aha Moment) - Redis Session Store의 등장
해결책은 의외로 간단했다. 세션을 각 서버 메모리가 아니라, 별도의 중앙 집중식 저장소에 넣는 것이다. 그 저장소가 바로 Redis다.
Redis란?
- In-Memory Key-Value Store: 메모리에 데이터를 저장해서 초고속 읽기/쓰기가 가능하다.
- TTL (Time To Live): 데이터에 만료 시간을 줄 수 있다. 1시간 뒤 자동 삭제 같은 게 가능.
- Persistence: RDB, AOF 같은 기능으로 메모리 데이터를 디스크에 백업할 수 있다.
아키텍처
[사용자]
↓
[Load Balancer]
↓
[Web Server A] ←→ [Redis Cluster] ←→ [Web Server B]
[Web Server C] ↗
이제 모든 웹 서버가 "Redis야, 이 세션 ID 있어?"라고 물어본다. Redis는 모든 서버가 공유하는 "중앙 금고"가 되는 것이다.
실제 코드: Express.js + Redis Session
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대로 늘려도 로그인이 유지됐다. 이때 처음으로 "확장 가능한 인증 시스템"을 만들었다는 걸 이해했다.
8. 보안 속성 3대장 (HttpOnly, Secure, SameSite) 뜯어보기
세션을 제대로 이해한 뒤, 다음 단계는 보안이었다. 쿠키 하나 잘못 설정하면 사이트 전체가 털릴 수 있다는 걸 알게 됐다.
1) HttpOnly: XSS 방어의 핵심
cookie: {
httpOnly: true // JavaScript document.cookie로 접근 불가
}
이 옵션을 켜면, 해커가 악성 스크립트를 심어도 document.cookie로 쿠키를 읽을 수 없다. XSS 공격의 90%를 막는 가장 중요한 옵션이다.
내가 처음 localStorage에 JWT를 저장했을 때 터졌던 이유가 바로 이거다. localStorage는 JS로 접근 가능하니까, 악성 스크립트가 토큰을 훔쳐갔던 것이다.
2) Secure: HTTPS 전용 전송
cookie: {
secure: true // HTTPS 연결에서만 쿠키 전송
}
HTTP(비암호화)로는 쿠키가 전송되지 않는다. 카페 공용 와이파이 같은 곳에서 패킷을 스니핑해도, 암호화된 HTTPS 안에 쿠키가 숨어있어서 볼 수 없다.
3) SameSite: CSRF 방어
cookie: {
sameSite: 'strict' // 또는 'lax', 'none'
}
- Strict: 같은 도메인(Same-Site)에서만 쿠키 전송. 가장 안전하지만, 다른 사이트에서 링크 타고 들어와도 쿠키가 안 붙어서 로그인이 풀리는 것처럼 보임.
- Lax (기본값): GET 요청으로 링크 타고 들어올 때는 쿠키 전송 허용. POST 같은 상태 변경 요청은 차단.
- None: 크로스 사이트에서도 쿠키 전송. 단,
Secure와 함께 써야 함. (유튜브 임베드, 페이스북 좋아요 버튼 같은 서드파티 위젯이 이걸 씀)
종합 예시
cookie: {
httpOnly: true, // XSS 방어
secure: true, // 네트워크 스니핑 방어
sameSite: 'lax', // CSRF 방어 + UX 균형
maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
domain: '.example.com', // 서브도메인 공유
path: '/' // 모든 경로에서 유효
}
이 설정만 제대로 해도, 웬만한 쿠키 관련 보안 문제는 막을 수 있다.
9. 실제 공격 사례 - 세션 고정 공격 (Session Fixation)
이론으로만 듣던 공격을 실제로 재현해보니, 진짜로 무섭더라.
공격 시나리오
- 해커가 은행 사이트(
bank.com)에 접속해서 유효한 세션 ID를 받는다.SID=attacker123 - 해커가 피해자에게 이메일을 보낸다. "은행에서 보안 업데이트 확인하세요:
http://bank.com/login?JSESSIONID=attacker123" - 피해자가 링크를 클릭하고 로그인한다. 서버는
SID=attacker123를 피해자의 세션으로 승격시킨다. - 해커는 이미
SID=attacker123를 알고 있으므로, 피해자의 계정으로 접속한다.
방어법: Session Regeneration
로그인 성공 시, 기존 세션 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)는 이걸 기본으로 해준다. 하지만 직접 구현할 때는 반드시 챙겨야 한다.
10. 패러다임 전환 - JWT의 등장과 Stateless 인증
세션 방식이 완벽해 보였지만, 치명적인 단점이 하나 있었다. 서버가 상태(State)를 가져야 한다는 것.
- Redis가 죽으면 모든 사용자 로그아웃
- 세션 DB가 거대해지면 조회 속도 저하
- 마이크로서비스 아키텍처(MSA)에서 서비스마다 세션 DB를 공유해야 함
그래서 나온 게 JWT (JSON Web Token)다. 핵심 아이디어는 이거다. "서버가 정보를 저장하지 말고, 정보 자체를 암호화해서 클라이언트에게 주자."
JWT 구조
JWT는 Header.Payload.Signature 3부분으로 나뉜다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // Header (Base64)
.
eyJ1c2VySWQiOjUwLCJyb2xlIjoiYWRtaW4ifQ // Payload (Base64)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature (HMAC-SHA256)
- Header: 알고리즘 정보 (
{"alg": "HS256", "typ": "JWT"}) - Payload: 실제 데이터 (
{"userId": 50, "role": "admin", "exp": 1735689600}) - Signature:
HMACSHA256(base64(Header) + "." + base64(Payload), SECRET_KEY)
검증 방식
서버는 DB를 조회하지 않는다. 대신:
- JWT를 받으면 Header와 Payload를 가져온다.
- 자기가 가진 SECRET_KEY로 Signature를 재계산한다.
- 재계산한 값과 JWT의 Signature가 일치하면, "이건 내가 발급한 게 맞네" 인증 완료.
DB 조회가 없으니 빠르다. Redis도 필요 없다. 서버가 Stateless해진다.
실제 코드 - Node.js JWT 발급/검증
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 });
});
11. JWT의 치명적 약점 - 강제 로그아웃이 안 된다
JWT를 쓰면서 깨달은 가장 큰 문제점이 있었다. 한 번 발급된 토큰은 만료 시간이 올 때까지 유효하다. 서버가 "취소"할 방법이 없다.
시나리오
- 사용자가 로그인해서 JWT를 받았다. (만료: 24시간)
- 30분 뒤, 회사 보안팀이 해당 계정의 이상 행동을 감지했다. "강제 로그아웃"을 시도한다.
- 하지만... 서버가 할 수 있는 게 없다. JWT는 서버에 저장되어 있지 않다. 사용자가 가지고 있을 뿐이다.
- 해커는 남은 23.5시간 동안 계속 접근 가능하다.
해결책: JWT + Redis Blacklist
결국 "완전한 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 조회 빈도가 낮다. (로그아웃한 토큰만 저장하면 되니까)
12. 최고의 조합 - Refresh Token + Access Token 패턴
실제로 가장 많이 쓰이는 패턴이 이거다. JWT의 장점(빠른 검증)과 세션의 장점(강제 취소 가능)을 결합한 하이브리드 방식이다.
전략
- Access Token (JWT): 수명이 짧다. (15분~1시간) API 호출할 때마다 헤더에 붙인다.
- Refresh Token: 수명이 길다. (7일~30일)
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: '로그아웃 완료' });
});
왜 이게 최선인가?
- 보안: Refresh Token은
HttpOnly쿠키에 있어서 XSS로 훔칠 수 없다. - 유연성: Access Token이 탈취돼도 15분만 유효하다. 피해를 최소화.
- 강제 로그아웃 가능: Refresh Token을 DB에서 삭제하면, 다음 갱신 요청이 실패한다.
- 성능: 대부분의 요청은 Access Token(JWT)만 검증하므로 빠르다. DB 조회는 15분에 한 번만.
결국 이거였다. "은총알(Silver Bullet)은 없다. 상황에 맞는 조합이 최선이다."
13. 실제 의사결정 - 언제 무엇을 써야 할까?
| 상황 | 추천 기술 | 이유 |
|---|---|---|
| 관리자 페이지 / 사내 시스템 | Session (+ Redis) | 보안 최우선. 강제 로그아웃/중복 로그인 방지 필요. 사용자 수 적음. |
| 대규모 B2C 서비스 (쿠팡, 넷플릭스) | JWT (Refresh + Access) | 서버 비용 절감. 모바일 앱과 호환성 좋음. |
| 은행 / 금융권 | Session (+ IP/디바이스 검증) | 절대적 보안 요구. 이상 행동 감지 시 즉시 차단. |
| 마이크로서비스 (MSA) | JWT | 서비스 간 통신 시 DB 조회 없이 인증 가능. Gateway에서 검증. |
| OAuth 제공자 (구글, 페이스북) | JWT (+ Opaque Refresh Token) | 표준 스펙(RFC 6749). 서드파티 앱과 연동 용이. |
내가 내린 결론은 이거다. "초기 MVP는 Session. 트래픽이 늘면 JWT 전환. 단, Refresh Token은 반드시 서버에 저장."
14. 마무리 - 내가 정리한 3가지 교훈
-
쿠키는 전송 수단, 세션은 저장 방식: 쿠키와 세션은 대립 관계가 아니라 협력 관계다. 세션 ID를 쿠키에 담아 보내는 게 전통적인 방식이다.
-
Stateless는 이상향이지, 현실이 아니다: JWT가 완전히 Stateless하려면 강제 로그아웃을 포기해야 한다. 실제로는 Refresh Token을 DB에 저장하는 하이브리드 방식이 답이다.
-
보안은 옵션이 아니라 필수:
HttpOnly,Secure,SameSite를 빼먹으면 언젠가 해킹당한다. 나는 4년 전에 이미 당했다. 여러분은 지금 배워서 다행이다.
이 글이 누군가의 삽질을 줄여줬으면 좋겠다. 나는 이 내용을 정리하는 데 3년이 걸렸다. 여러분은 30분이면 충분하다.
Cookie vs Session: My Journey from Getting Hacked to Understanding State Management
1. The Day My First Login System Got Compromised
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.
2. The Root Problem: HTTP's Memory Loss
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.
3. First Solution: Cookie - "The Post-It Note on My Forehead"
When I first understood Cookies, I came up with this metaphor: "The server sticks a Post-It note on my forehead."
How It Works
- Issuance: When I log in, the server responds with
Set-Cookie: user=ratia; Max-Age=3600in the header. - Storage: The browser saves this cookie to local disk or memory.
- Transmission: Every subsequent request to the same domain, the browser automatically attaches
Cookie: user=ratiain 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)
First Disappointment: Cookie's Limitations
But after using Cookies directly, the problems became clear:
- Security Vulnerability: Cookies are just text files. Open DevTools and you'll see
user=ratiain plain text. Someone with brief access to my computer can copy the cookie. Worse, they could modify it touser=adminand send that—if the server naively trusts it, you're compromised. - Size Limit: Cookies max out at 4KB. Not enough to store user profile pictures, permission lists, etc.
- Traffic Waste: Cookies attach to every request. Even loading a single image or calling an API sends the cookie along. Large cookies mean unnecessary network overhead.
I finally understood: Cookies are a transport mechanism, not a vault for sensitive data.
4. Second Solution: Session - "The Key to the Server's Safe"
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."
How It Works
- Vault Creation: When a user logs in, the server creates a "vault" in memory or database. Inside the vault goes the actual information:
{ id: 50, role: 'admin', email: 'ratia@example.com' }. - Key Issuance: The server generates a random string "key" (Session ID) to open this vault. Something like
JSESSIONID=a3fWxSvYh9Kp—a meaningless long random string. - Transmission: This key is sent to the client in a Cookie:
Set-Cookie: JSESSIONID=a3fWxSvYh9Kp; HttpOnly; Secure - Verification: When the client sends a request with the key (Cookie), the server opens the vault and confirms: "Ah, it's ratia."
The Core Difference
| 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.
5. First Real Implementation: Express.js Session Code
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.
6. The Scaling Crisis: When Horizontal Scaling Breaks Sessions
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.
The Root Cause
- User logs into Server A. (Session created in Server A's memory)
- Next request goes through load balancer to Server B.
- Server B checks its own memory: "Who are you? I don't have that session."
- Login state lost.
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."
7. The Turning Point: Redis Session Store
The solution was surprisingly simple: Store sessions in a separate centralized store instead of each server's memory. That store is Redis.
What is Redis?
- In-Memory Key-Value Store: Stores data in RAM for blazing-fast read/write.
- TTL (Time To Live): You can set expiration times on data. Auto-delete after 1 hour, etc.
- Persistence: Features like RDB and AOF can backup memory data to disk.
Architecture
[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.
Production Code: Express.js + Redis Session
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.
8. Security Deep Dive: The Trinity of Cookie Attributes
After properly understanding sessions, the next step was security. One misconfigured cookie can compromise your entire site.
1) HttpOnly: The XSS Defense Cornerstone
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.
2) Secure: HTTPS-Only Transmission
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.
3) SameSite: CSRF Defense
cookie: {
sameSite: 'strict' // or 'lax', 'none'
}
- Strict: Cookie only sent for same-site requests. Most secure but inconvenient UX (login state appears lost when arriving from external links).
- Lax (default): Allows cookies when navigating via links (GET requests). Blocks state-changing requests (POST).
- None: Send cookies cross-site. Requires
Secure. (Used by third-party widgets like YouTube embeds, Facebook Like buttons)
Comprehensive Example
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.
9. Real Attack Case Study: Session Fixation
I recreated an attack I'd only heard about in theory. Genuinely scary.
Attack Scenario
- Attacker visits bank site (
bank.com) and receives valid Session ID:SID=attacker123 - Attacker sends victim an email: "Check security update from bank:
http://bank.com/login?JSESSIONID=attacker123" - Victim clicks link and logs in. Server associates
SID=attacker123with victim's session. - Attacker already knows
SID=attacker123, so they access victim's account.
Defense: Session Regeneration
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.
10. Paradigm Shift: JWT and Stateless Authentication
Sessions seemed perfect, but they had one fatal flaw: Servers must maintain state.
- If Redis dies, all users get logged out
- As session DB grows, query speed degrades
- In microservices architecture (MSA), every service must share the session DB
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 Structure
JWT has 3 parts: Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // Header (Base64)
.
eyJ1c2VySWQiOjUwLCJyb2xlIjoiYWRtaW4ifQ // Payload (Base64)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature (HMAC-SHA256)
- Header: Algorithm info (
{"alg": "HS256", "typ": "JWT"}) - Payload: Actual data (
{"userId": 50, "role": "admin", "exp": 1735689600}) - Signature:
HMACSHA256(base64(Header) + "." + base64(Payload), SECRET_KEY)
Verification Process
Server doesn't query a database. Instead:
- Receive JWT, extract Header and Payload
- Recalculate Signature using own SECRET_KEY
- If recalculated value matches JWT's Signature: "This is one I issued" → Authentication complete
No DB lookup, so it's fast. No Redis needed. Server becomes Stateless.
Production Code: Node.js JWT Issuance/Verification
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 });
});
11. JWT's Fatal Weakness: No Forced Logout
The biggest problem I discovered with JWT: Once issued, a token remains valid until expiration. The server has no way to "revoke" it.
Scenario
- User logs in, receives JWT (expires in 24 hours)
- 30 minutes later, security team detects suspicious activity. They attempt "forced logout."
- But... the server can't do anything. JWT isn't stored on the server. The user possesses it.
- Attacker continues access for remaining 23.5 hours.
Solution: JWT + Redis Blacklist
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).
12. The Best Combination: Refresh Token + Access Token Pattern
The most widely used pattern in production environments. A hybrid approach combining JWT's strength (fast verification) with Session's strength (revocable).
Strategy
- Access Token (JWT): Short lifespan (15 min to 1 hour). Attached to API call headers.
- Refresh Token: Long lifespan (7 to 30 days). Stored in
HttpOnlycookie. Stored in DB or Redis for verification.
Flow Diagram
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)
Production Code
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' });
});
Why This is Optimal
- Security: Refresh Token in
HttpOnlycookie can't be stolen via XSS. - Flexibility: Even if Access Token is compromised, it's only valid for 15 minutes. Minimizes damage.
- Forced Logout Possible: Delete Refresh Token from DB, next refresh request fails.
- Performance: Most requests only verify Access Token (JWT), which is fast. DB query only once every 15 minutes.
I realized: "There's no silver bullet. Context-appropriate combinations are optimal."
13. Real-World Decision Making: When to Use What
| 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."