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