---
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]]
