Files
2nd/10_Wiki/Topics/Coding/Security_Session_vs_JWT.md
T
2026-05-09 22:47:42 +09:00

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
security
session
jwt
vibe-coding
language applicable_to
TS
Backend
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 (전통)

// 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:
+ 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.

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

// 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.

🔗 관련 문서