세션 · JWT · OAuth2 · SSO — 토큰 3형제
서버 세션, 자체 서명 JWT, 남의 계정으로 로그인(OAuth), 한 번 로그인 = 여러 서비스(SSO).
"로그인 구현"은 바이브코더 1순위 요청. 하지만 세션·JWT·OAuth는 각각 강점이 다르고 섞어 쓸 때 보안 구멍이 가장 많다. AI가 만든 토큰 코드가 안전한지 판단할 수 있어야 한다.
① 서버 세션(Session) — 전통 방식. 로그인 성공하면 서버가 세션 ID를 발급 → 쿠키로 보냄. 서버는 세션 저장소(Redis 등)에 "이 ID = 유저 42" 기록. 매 요청마다 서버가 저장소에서 조회.
[클라이언트] [서버] [세션 저장소]
login ──────────────────────> ✓
세션 생성 ────────────> sess_abc = {userId:42}
Set-Cookie: sid=abc <──────── 응답
다음 요청
Cookie: sid=abc ─────────────> 조회 ─────────────> sess_abc 읽기
검증 후 처리세션은 서버가 상태를 쥐고 있다 → 로그아웃 즉시 강제 가능. 단 서버 재시작/스케일아웃 시 저장소 공유 필요(Redis 권장).
② JWT(JSON Web Token) — 자체 서명 토큰. 서버가 유저 정보를 JSON에 담고 비밀키로 서명해서 통째로 클라이언트에 준다. 서버는 저장소 없이 서명만 검증하면 신뢰 가능.
JWT 구조 (점으로 3등분)
헤더.페이로드.서명
eyJhbGciOiJIUzI1NiJ9 . eyJ1aWQiOjQyLCJleHAiOjE3MDB9 . X7t...
Base64 디코드 하면:
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "uid": 42, "role": "admin", "exp": 1700000000 }
Signature: HMAC_SHA256(base64(header)+"."+base64(payload), SECRET)페이로드는 누구나 디코드해서 읽을 수 있다(암호화 ❌, 서명만). 서명이 있어야 서버가 발급한 진짜임이 증명된다.
| 항목 | 세션 | JWT |
|---|---|---|
| 상태 | 서버가 쥐고 있음 (stateful) | 무상태 (stateless) |
| 저장소 | Redis/DB 필요 | 필요 없음 |
| 강제 로그아웃 | 즉시 가능 | 어려움 (블랙리스트 필요) |
| 스케일아웃 | 저장소 공유 필요 | 자연스럽게 확장 |
| 크기 | 쿠키 16자 | 토큰 수백 바이트 |
| 어울리는 곳 | 웹 앱, 관리자 콘솔 | API, 모바일, 서비스 간 통신 |
JWT는 보통 2개를 쌍으로 쓴다. Access Token(짧은 수명, 15분)으로 API 호출 → 만료되면 Refresh Token(긴 수명, 2주)으로 새 Access 발급. Refresh는 반드시 HttpOnly 쿠키, Access는 메모리나 Authorization 헤더.
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
// 로그인 성공 시 — 2종 발급
function issueTokens(user: { id: string; role: string }) {
const access = jwt.sign(
{ uid: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: "15m" }
);
const refresh = jwt.sign(
{ uid: user.id, ver: 1 },
REFRESH_SECRET,
{ expiresIn: "14d" }
);
return { access, refresh };
}
// API 보호 미들웨어
function authRequired(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
try {
req.user = jwt.verify(token, ACCESS_SECRET); // 서명 검증
next();
} catch {
return res.status(401).json({ error: "invalid token" });
}
}
// ⚠️ 페이로드에 절대 넣지 말 것
// - 비밀번호, 주민번호, 카드번호
// - 자주 바뀌는 권한 (JWT는 만료까지 그 권한 유지됨)Access를 짧게 두는 이유: 탈취돼도 15분 후 무효. Refresh는 HttpOnly 쿠키에 담아 XSS로 못 훔치게 한다.
③ OAuth2 — 남의 계정으로 로그인. "Google로 로그인" 버튼. 내 서버는 비밀번호를 모르고, Google이 대신 인증해주고 "이 사람이 구글 ID xxx입니다"라는 증표(access token)를 내 서버로 보낸다.
OAuth2 Authorization Code Flow (가장 표준)
[유저] → [내 앱] "Google 로그인 클릭"
↓ (리다이렉트)
[유저] → [Google] 로그인 + 동의 화면
↓ (리다이렉트 + code)
[내 앱] → [Google 서버] code + client_secret 교환
← access_token + id_token + refresh_token
[내 앱] → [Google API] access_token으로 프로필 조회
← { id: "...", email: "..." }
[내 앱] → 내 DB에 유저 upsert → 내 JWT 발급 → 쿠키로 응답순서 외우기보단 흐름 감각. 핵심은 내 서버가 사용자 비밀번호를 절대 받지 않는다는 점.
| 용어 | 뜻 |
|---|---|
| Resource Owner | 유저 (자기 데이터의 주인) |
| Client | 내 앱 (jit.co.kr) |
| Authorization Server | Google / Kakao / GitHub |
| Resource Server | Google API 등 실제 데이터 |
| Scope | 요청할 권한 범위 (profile, email, drive.file 등) |
| PKCE | 모바일·SPA용 보안 확장 (code verifier/challenge) |
OpenID Connect (OIDC)는 OAuth2 위에 얹은 '로그인 전용' 표준. `id_token`(JWT)에 유저 정보를 담아 신원 확인까지 한 방에. "Google로 로그인" 대부분이 실제로는 OIDC. 바이브코더는 Supabase Auth·Auth0·Clerk·NextAuth 같은 라이브러리로 처리하는 게 현실적.
④ SSO(Single Sign-On) — 한 번 로그인 = 여러 서비스. 회사에서 구글 계정 한 번 로그인하면 Gmail·Drive·캘린더 다 들어간다. 기업용은 SAML 표준도 많이 쓰임. 바이브코더가 직접 구현할 일은 드물고 Clerk·Auth0·Auth.js로 위임.
| 저장 위치 | XSS 안전? | CSRF 안전? | 추천 용도 |
|---|---|---|---|
| HttpOnly 쿠키 | ✅ | ⚠️ (SameSite로 방어) | Refresh 토큰, 세션 쿠키 |
| 메모리 (JS 변수) | ✅ | ✅ | Access 토큰 (SPA) |
| localStorage | ❌ | ✅ | 쓰지 말 것 |
| sessionStorage | ❌ | ✅ | 쓰지 말 것 |
localStorage에 토큰 넣으면 안 된다. XSS 한 방에 탈취. 현대 표준: Refresh는 HttpOnly+Secure+SameSite 쿠키, Access는 메모리 또는 Authorization 헤더로만.
로그인 구현할 땐 비밀번호 저장도 같이 배운다. 평문 저장 = 유출 시 파멸. 단순 MD5/SHA1 = 레인보우 테이블로 뚫림. 현대 표준: bcrypt / argon2 / scrypt (느리게 설계된 해시 + salt 자동).
import bcrypt from "bcrypt";
// 회원가입 — 저장 시
const hash = await bcrypt.hash(plainPassword, 12); // 12 라운드 = 느리게
await db.users.insert({ email, passwordHash: hash });
// 로그인 — 비교 시
const user = await db.users.findByEmail(email);
const ok = await bcrypt.compare(plainPassword, user.passwordHash);
if (!ok) return res.status(401).end();
// ❌ 절대 하지 말 것
// - SHA1(password)
// - MD5(password)
// - "같은 비밀번호면 같은 해시" 패턴 (salt 없음)Claude가 만든 인증 코드에 `bcrypt` 또는 `argon2`가 안 보이면 반드시 교체 지시. 라운드 12는 현재 표준(2026년 기준 CPU에서 ~0.3초).
Claude에게 로그인 요청할 때 이렇게. "비밀번호는 bcrypt 12라운드로 해싱. Access JWT는 15분, Refresh는 14일 HttpOnly+Secure+SameSite=Lax 쿠키. Refresh로 Access 재발급 엔드포인트 추가. 로그아웃은 Refresh 쿠키 삭제 + 서버 측 jti 블랙리스트." — 구체적일수록 AI 구현이 정확해진다.
한 줄 요약: 단순 웹은 세션+Redis, API·모바일은 JWT(Access 짧게 + Refresh 쿠키), 소셜로그인은 OAuth2/OIDC를 라이브러리로. 비밀번호는 무조건 bcrypt/argon2. localStorage 토큰 저장 금지.
JWT 페이로드는 암호화되어 있어 외부에서 내용을 볼 수 없다.
XSS 공격에 강한 토큰 저장 위치는?
비밀번호를 저장할 때 쓰는 현대 표준 해시 알고리즘 2개를 쓰시오.
OAuth2 Authorization Code Flow에서 내 서버는 사용자의 Google 비밀번호를 받는다.
짧은 수명 JWT로 API 호출하고, 만료되면 긴 수명 토큰으로 재발급 받는 2종 토큰 구조에서 각 토큰의 이름은?