11 KiB
11 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| security-session-vs-jwt | Session vs JWT — Trade-off / 결정 | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
Session vs JWT
가장 자주 토론. Session = stateful, server lookup. JWT = stateless, self-contained. 정답 없음 — trade-off. Refresh + short JWT 가 modern.
📖 핵심 개념
- Session: server 가 ID → user 매핑.
- JWT: signed token + claims.
- Stateless: server lookup 없음.
- Revocation: invalidate.
💻 코드 패턴
Session (전통)
// Login
const sessionId = crypto.randomUUID();
await db.sessions.create({
id: sessionId,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 3600_000),
ip, userAgent,
});
res.cookie('session', sessionId, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 3600_000,
});
// Middleware
app.use(async (req, res, next) => {
const sessionId = req.cookies.session;
const session = await db.sessions.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
return res.status(401).end();
}
req.user = session.user;
next();
});
→ 매 request 가 DB lookup.
JWT (stateless)
import jwt from 'jsonwebtoken';
// Login
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET!,
{ expiresIn: '15m' }
);
res.cookie('token', token, { httpOnly: true, secure: true });
// Middleware
app.use((req, res, next) => {
const token = req.cookies.token;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
req.userId = payload.userId;
next();
} catch {
res.status(401).end();
}
});
→ DB lookup 없음 — 빠름.
Refresh + Access (modern, best-of-both)
// Login
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET!,
{ expiresIn: '15m' } // 짧음
);
const refreshToken = crypto.randomBytes(32).toString('hex');
await db.refreshTokens.create({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 24 * 3600_000), // 30 days
});
return { accessToken, refreshToken };
// Access expire → refresh
async function refresh(refreshToken: string) {
const stored = await db.refreshTokens.findUnique({ where: { token: refreshToken } });
if (!stored || stored.expiresAt < new Date() || stored.used) {
// Reuse detection — revoke all
await db.refreshTokens.deleteMany({ where: { userId: stored?.userId } });
throw new Error('Token revoked');
}
const newRefresh = crypto.randomBytes(32).toString('hex');
await db.refreshTokens.update({
where: { id: stored.id },
data: { token: newRefresh, refreshedAt: new Date() },
});
const accessToken = jwt.sign({ userId: stored.userId }, secret, { expiresIn: '15m' });
return { accessToken, refreshToken: newRefresh };
}
→ Access = stateless (15 min). Refresh = stateful (revoke 가능).
Session 의 장점
+ Server 가 즉시 invalidate
+ Update (role 변경 즉시 적용)
+ Track active sessions
+ 작은 token (just session ID)
+ 단순
Session 의 단점
- 매 request DB lookup
- Multi-server = shared store (Redis)
- Cross-domain 어려움 (same-origin cookie)
JWT 의 장점
+ Stateless — DB lookup 없음
+ Microservice 간 share
+ Cross-domain (header)
+ Mobile-friendly
+ Auth + claims (role, scope)
JWT 의 단점
- Revoke 어려움 (until expire)
- 큰 (signed claims + signature)
- Sensitive data → 큰 cookie
- Rotation 복잡
- Algorithm confusion (alg=none, RSA→HMAC)
JWT 함정 #1: alg=none
// ❌ Don't trust alg from token
const decoded = jwt.decode(token); // verify 안 함
// ✅ 항상 verify with specific algorithm
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
→ "alg=none" attack — verify 강제 + algorithm 명시.
JWT 함정 #2: Algorithm confusion
RSA key 가 HMAC secret 으로 사용 — RSA public 가 attacker 의 HMAC secret.
→ algorithms: ['RS256'] strict.
JWT 함정 #3: Sensitive in payload
JWT payload = base64 (decoded easily).
❌ password, email, full name in payload
✅ user ID + minimal claims
JWT 함정 #4: Expiration 무
// ❌
jwt.sign({ userId }, secret); // 영원
// ✅
jwt.sign({ userId }, secret, { expiresIn: '15m' });
JWT secret rotation
const SECRETS = [
{ kid: 'v2', value: 'new-secret' }, // current
{ kid: 'v1', value: 'old-secret' }, // grace period
];
// Sign with v2
jwt.sign(payload, SECRETS[0].value, {
keyid: 'v2',
});
// Verify — try all
function verify(token: string) {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded.header.kid;
const secret = SECRETS.find(s => s.kid === kid)?.value;
return jwt.verify(token, secret!);
}
→ 점진 rotation.
Revocation strategies (JWT)
1. Short expiry (15 min)
- Revoke 까지 wait time
2. Blacklist (Redis)
- Logout 시 token blacklist 까지 expire
- Stateful (defeats purpose 일부)
3. JWT version (user 별)
- User table 의 token_version
- Token 의 version 비교
- User 가 logout-all → version++
4. Refresh token (stateful) + access (stateless)
- 가장 인기
Redis blacklist
// Logout
await redis.set(`blacklist:${tokenId}`, '1', 'EX', tokenRemainingTtl);
// Verify
async function verify(token: string) {
const decoded = jwt.verify(token, secret) as JwtPayload;
if (await redis.exists(`blacklist:${decoded.jti}`)) {
throw new Error('Token revoked');
}
return decoded;
}
→ Redis lookup — but cheap.
User-level revoke
// User table
ALTER TABLE users ADD COLUMN token_version INT DEFAULT 0;
// Sign
jwt.sign({ userId, tokenVersion: user.tokenVersion }, secret);
// Verify
const payload = jwt.verify(token, secret) as JwtPayload;
const user = await db.users.findUnique({ where: { id: payload.userId } });
if (user.tokenVersion !== payload.tokenVersion) {
throw new Error('Token revoked');
}
// Force logout-all
await db.users.update({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
→ Stateless verify + DB lookup 하지만 cache 가능.
Multi-service (microservice)
JWT 가 자연:
- Each service verify (no central DB)
- Public key (RS256) — 다른 service 가 verify
- Single sign-on
Session 도 OK:
- Shared session store (Redis)
- 모든 service 가 lookup
Mobile (long-lived)
Access token: 15 min (memory)
Refresh token: 30 days (Keychain / Keystore)
App start:
1. Memory 의 access?
2. Expired? → refresh
3. Refresh expired? → re-login
Cookie vs header
Cookie:
+ Browser 자동 (CSRF 방어 with SameSite)
+ HttpOnly = XSS 방어
- CORS 어려움
Header (Authorization: Bearer ...):
+ Cross-domain easy
+ Mobile native
- XSS = token leak 가능 (localStorage)
→ Web app = cookie. Mobile / API = header.
CSRF protection (cookie)
// SameSite=Strict 또는 Lax
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// Or double-submit
const csrfToken = generateToken();
res.cookie('csrf', csrfToken, { sameSite: 'strict' });
res.locals.csrfToken = csrfToken; // form 안 hidden field
// Server verify
if (req.body.csrfToken !== req.cookies.csrf) {
return res.status(403).end();
}
XSS protection
// HttpOnly cookie — XSS 가 token 못 훔침
res.cookie('token', token, { httpOnly: true });
// 또는 BFF pattern
// Client = httpOnly cookie
// Server (BFF) = JWT 에 access token 보관
// External API call 시 BFF 가 JWT 추가
Stolen token (mitigation)
1. Short access (15 min)
2. Refresh rotation (reuse detection)
3. Device fingerprint
4. IP / location anomaly
5. User notify on new device
6. Auto logout on suspicious activity
Concurrent session
허용?
- Spotify: 다중 device 가능
- 은행: 1 device only
- 스트리밍: 1-3 device
Implementation:
- Session table 가 N session per user
- N+1 회 login 시 가장 옛 session delete
- "다른 device 가 로그아웃" notification
"Stay logged in" / "Remember me"
const expiresIn = rememberMe ? '30d' : '24h';
// Or session vs JWT 의 cookie maxAge
const maxAge = rememberMe ? 30 * 24 * 3600 * 1000 : undefined; // session cookie
CSRF + JWT (header) 의 함정
Cookie + JWT = 자동 send → CSRF 위험.
Header + JWT = manual send (JS 가 read) → XSS 위험.
Best:
- HttpOnly cookie + SameSite (CSRF 차단)
- Short JWT
- Refresh rotation
iOS / Android 의 secure storage
// iOS Keychain
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: 'refreshToken',
kSecValueData: refreshToken.data(using: .utf8)!,
] as CFDictionary
SecItemAdd(query, nil)
// Android EncryptedSharedPreferences
val prefs = EncryptedSharedPreferences.create(
"auth", masterKey, context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
prefs.edit().putString("refreshToken", token).apply()
→ OS-level encryption.
Auth0 / Clerk / Supabase (managed)
Self-host 어려움 — managed:
- Auth0
- Clerk
- Supabase Auth
- WorkOS
- Stytch
→ Token / session / OAuth / MFA 모두 처리.
NextAuth / Auth.js (Next.js)
import NextAuth from 'next-auth';
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [
GoogleProvider({ clientId: ..., clientSecret: ... }),
CredentialsProvider({ ... }),
],
session: { strategy: 'jwt' }, // 또는 'database'
});
→ Cookie + JWT + DB session 자동.
Session strategy 결정
Web app + 작은 traffic:
→ Database session.
Microservices:
→ JWT (or Redis session).
Mobile:
→ Refresh + access JWT.
Cross-domain SaaS:
→ JWT.
Internal tool:
→ Session (단순).
큰 SaaS:
→ Refresh + access (best-of-both).
🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 단순 web app | DB session |
| Microservices | JWT (RS256) |
| Mobile | Refresh + access |
| Multi-domain | JWT |
| 작은 internal | Session |
| 큰 SaaS | Refresh rotation |
❌ 안티패턴
- JWT secret weak / leak: 모든 token 위조.
- alg 무 verify: alg=none attack.
- Long-lived JWT (1 year): revoke 어려움.
- Sensitive in JWT payload: leak.
- localStorage 의 JWT: XSS.
- No expiry: token 영원.
- Refresh rotation 무: stolen = 영원.
- HttpOnly 무: XSS leak.
🤖 LLM 활용 힌트
- Refresh + short access JWT = best.
- HttpOnly + Secure + SameSite cookie.
- algorithm strict + secret rotation.
- Mobile = Keychain / Keystore.
- 단순 web = DB session.