[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
---
|
||||
id: security-session-vs-jwt
|
||||
title: Session vs JWT — Trade-off / 결정
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [security, session, jwt, vibe-coding]
|
||||
tech_stack: { language: "TS", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [session, JWT, token, refresh token, stateless, stateful, revocation]
|
||||
---
|
||||
|
||||
# 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 (전통)
|
||||
```ts
|
||||
// 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)
|
||||
```ts
|
||||
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)
|
||||
```ts
|
||||
// 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 };
|
||||
```
|
||||
|
||||
```ts
|
||||
// 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
|
||||
```ts
|
||||
// ❌ 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 무
|
||||
```ts
|
||||
// ❌
|
||||
jwt.sign({ userId }, secret); // 영원
|
||||
|
||||
// ✅
|
||||
jwt.sign({ userId }, secret, { expiresIn: '15m' });
|
||||
```
|
||||
|
||||
### JWT secret rotation
|
||||
```ts
|
||||
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
|
||||
```ts
|
||||
// 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
|
||||
```ts
|
||||
// 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)
|
||||
```ts
|
||||
// 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();
|
||||
}
|
||||
```
|
||||
|
||||
→ [[Security_CSRF_Patterns]].
|
||||
|
||||
### XSS protection
|
||||
```ts
|
||||
// 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"
|
||||
```ts
|
||||
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
|
||||
```swift
|
||||
// iOS Keychain
|
||||
let query = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: 'refreshToken',
|
||||
kSecValueData: refreshToken.data(using: .utf8)!,
|
||||
] as CFDictionary
|
||||
SecItemAdd(query, nil)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// 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)
|
||||
```ts
|
||||
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.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Web_JWT_Patterns]]
|
||||
- [[Security_Login_Flows]]
|
||||
- [[Security_OAuth_Flows]]
|
||||
Reference in New Issue
Block a user