- Published on
SSO 통합 실전 가이드 — OIDC/OAuth2 + 쿠키/JWT 하이브리드 아키텍처, 토큰 로테이션 완전 정복
- Authors
- Name
- 개요 — SSO와 하이브리드 인증 아키텍처
- OIDC/OAuth2 프로토콜 심화
- 하이브리드 아키텍처 설계
- SSO 구현 실전
- 토큰 로테이션 전략
- 쿠키/JWT 하이브리드 패턴 상세
- 브라우저 저장소별 접근 가능성 표
- CORS + 멀티 도메인 SSO
- 로그아웃 전략
- 토큰 무효화 및 블랙리스트
- 멀티 서비스 인증 (마이크로서비스)
- 보안 트레이드오프 종합
- 프레임워크별 요약 비교표
- 통합 체크리스트
- 흔한 버그와 오해
- 참고자료
개요 — SSO와 하이브리드 인증 아키텍처
SSO란 무엇인가
Single Sign-On(SSO) 은 사용자가 하나의 자격 증명으로 여러 애플리케이션과 서비스에 접근할 수 있는 인증 메커니즘입니다. 한 번 로그인하면 같은 조직 내의 이메일, 위키, CI/CD, 사내 관리 도구 등에 별도의 로그인 없이 접근할 수 있습니다.
[사용자] ──로그인──▶ [IdP (Identity Provider)]
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[앱 A] [앱 B] [앱 C]
(이메일) (위키) (CI/CD)
재로그인 불필요 재로그인 불필요 재로그인 불필요
OIDC vs OAuth2 차이점
많은 개발자가 혼동하는 부분입니다. 명확히 구분하겠습니다.
- OAuth 2.0: 인가(Authorization) 프레임워크. "이 앱이 내 리소스에 접근해도 되는가?"를 결정합니다. Access Token을 발급하며, 사용자가 누구인지는 표준적으로 정의하지 않습니다.
- OpenID Connect (OIDC): OAuth 2.0 위에 구축된 인증(Authentication) 레이어. ID Token(JWT)을 발급하여 "이 사용자가 누구인지"를 증명합니다.
OAuth 2.0 = "이 앱이 네 Google Drive를 읽어도 돼?" (인가)
OIDC = "로그인한 사람이 youngjukim@example.com이야" (인증) + OAuth 2.0
왜 하이브리드(쿠키+JWT) 아키텍처가 필요한가
실제 프로덕션 환경에서는 쿠키만 쓰거나 JWT만 쓰는 방식은 각각 한계가 있습니다.
- 쿠키만: 브라우저-서버 간에는 자연스럽지만, 마이크로서비스 간 전파에 부적합하고 모바일 앱에서 사용이 어렵습니다.
- JWT만: 서비스 간 전파에 유리하지만, 브라우저에서 안전하게 저장하기가 어렵고(XSS 취약), 무효화가 즉시 불가능합니다.
하이브리드 아키텍처는 브라우저 ↔ BFF 구간에서는 HttpOnly 세션 쿠키를, BFF ↔ 백엔드 서비스 구간에서는 JWT를 사용하여 양쪽의 장점을 취합니다.
마이크로서비스 환경에서의 인증 과제
- 수십 개의 서비스가 각각 인증을 구현하면 유지보수 비용이 기하급수적으로 증가합니다.
- 서비스 간 호출 시 토큰 전파, audience 검증, 토큰 만료 핸들링을 통일해야 합니다.
- 세션 일관성: 한 서비스에서 로그아웃하면 다른 모든 서비스에서도 즉시 로그아웃되어야 합니다.
OIDC/OAuth2 프로토콜 심화
Authorization Code Flow + PKCE
가장 안전한 인증 흐름으로, 모든 클라이언트 유형(웹, 모바일, SPA)에 권장됩니다.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ BFF │ │ IdP │
│ (User) │ │ (Server) │ │(Keycloak)│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 1. /login 클릭 │ │
│─────────────────────────────────────▶│ │
│ │ 2. code_verifier 생성 │
│ │ code_challenge = │
│ │ SHA256(code_verifier) │
│ 3. 302 Redirect to IdP │ │
│◀─────────────────────────────────────│ │
│ (/authorize?response_type=code │ │
│ &client_id=... │ │
│ &code_challenge=... │ │
│ &code_challenge_method=S256) │ │
│──────────────────────────────────────────────────────────────────────▶│
│ │ 4. 사용자 로그인 + 동의 │
│◀──────────────────────────────────────────────────────────────────────│
│ 5. redirect_uri?code=AUTH_CODE │ │
│─────────────────────────────────────▶│ │
│ │ 6. POST /token │
│ │ code + code_verifier │
│ │─────────────────────────────▶│
│ │ 7. AT + RT + ID Token │
│ │◀─────────────────────────────│
│ 8. Set-Cookie: session_id │ │
│◀─────────────────────────────────────│ │
PKCE (Proof Key for Code Exchange, RFC 7636) 는 Authorization Code를 가로채는 공격을 방어합니다.
# Python: PKCE code_verifier / code_challenge 생성
import secrets
import hashlib
import base64
# 1. code_verifier: 43~128자 랜덤 문자열
code_verifier = secrets.token_urlsafe(64)[:128]
# 2. code_challenge: SHA256 해시 후 Base64url 인코딩
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
print(f"code_verifier: {code_verifier}")
print(f"code_challenge: {code_challenge}")
ID Token vs Access Token vs Refresh Token
| 토큰 | 목적 | 수명 | 수신자 | 형식 |
|---|---|---|---|---|
| ID Token | 사용자 인증 정보 전달 | 5~60분 | 클라이언트 앱 | JWT (반드시) |
| Access Token | API 접근 권한 부여 | 5~60분 | 리소스 서버 | JWT 또는 Opaque |
| Refresh Token | 새 AT/RT 발급 | 수일~수개월 | Authorization Server만 | Opaque 권장 |
ID Token의 주요 클레임:
{
"iss": "https://idp.example.com",
"sub": "user-uuid-1234",
"aud": "my-client-id",
"exp": 1709913600,
"iat": 1709910000,
"nonce": "abc123xyz",
"email": "youngjukim@example.com",
"name": "김영주",
"email_verified": true
}
sub: 사용자 고유 식별자 (절대 변경되지 않음)aud: 이 토큰을 발급받은 클라이언트 ID (반드시 검증해야 함)nonce: replay attack 방지용 (인증 요청 시 전달한 값과 일치 확인)
Access Token의 scope 예시:
scope: "openid profile email read:calendar write:calendar"
하이브리드 아키텍처 설계
BFF (Backend For Frontend) 패턴
BFF 패턴은 하이브리드 인증의 핵심입니다. 토큰은 BFF 서버에만 존재하고, 브라우저에는 세션 쿠키만 전달합니다.
┌─────────────────────────────────────────────────────┐
│ Browser │
│ 세션 쿠키만 보유 (HttpOnly, Secure, SameSite=Lax) │
│ JavaScript에서 토큰 접근 불가 → XSS로부터 안전 │
└──────────────────────┬──────────────────────────────┘
│ Cookie: session_id=xxx
▼
┌──────────────────────────────────────────────────────┐
│ BFF (Backend For Frontend) │
│ ┌──────────────────────────────────────────────┐ │
│ │ 세션 저장소 (Redis) │ │
│ │ session_id → { access_token, refresh_token, │ │
│ │ id_token, user_info } │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│ Authorization: Bearer <JWT>
▼
┌──────────────────────────────────────────────────────┐
│ Backend Microservices │
│ [User API] [Order API] [Payment API] [...] │
└──────────────────────────────────────────────────────┘
보안 이점:
- Access Token과 Refresh Token이 브라우저에 노출되지 않아 XSS 공격으로 토큰 탈취 불가
- CSRF 방어는 SameSite 쿠키 + CSRF 토큰으로 해결
- 토큰 갱신 로직이 서버에 집중되어 클라이언트 복잡도 감소
Token Relay 패턴
API Gateway에서 세션 쿠키를 JWT로 변환하여 백엔드에 전달합니다.
# Spring Cloud Gateway - Token Relay 설정
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- TokenRelay= # 자동으로 Access Token을 Authorization 헤더에 추가
- RemoveRequestHeader=Cookie # 쿠키는 백엔드로 전달하지 않음
SSO 구현 실전
Keycloak 연동 예시
Spring Boot + Spring Security OAuth2 Client:
// application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-app
client-secret: ${KEYCLOAK_SECRET}
scope: openid, profile, email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/my-realm
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/dashboard")
)
.oauth2Client(Customizer.withDefaults());
return http.build();
}
}
Django + mozilla-django-oidc:
# settings.py
INSTALLED_APPS += ['mozilla_django_oidc']
AUTHENTICATION_BACKENDS = [
'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
]
OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID']
OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET']
OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth'
OIDC_OP_TOKEN_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token'
OIDC_OP_USER_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/userinfo'
OIDC_OP_JWKS_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs'
OIDC_RP_SIGN_ALGO = 'RS256'
# urls.py
urlpatterns += [
path('oidc/', include('mozilla_django_oidc.urls')),
]
Next.js + NextAuth.js OIDC Provider:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'
const handler = NextAuth({
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER, // https://keycloak.example.com/realms/my-realm
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at
}
// 만료 전 갱신
if (Date.now() < (token.expiresAt as number) * 1000) {
return token
}
return refreshAccessToken(token)
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
return session
},
},
})
export { handler as GET, handler as POST }
Google/Azure AD/Okta 연동
OIDC Discovery endpoint(.well-known/openid-configuration)를 활용하면 어떤 IdP든 동일한 패턴으로 연동할 수 있습니다.
Google: https://accounts.google.com/.well-known/openid-configuration
Azure AD: https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Okta: https://{domain}.okta.com/.well-known/openid-configuration
Keycloak: https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration
// 범용 OIDC Discovery 활용 (Node.js)
import { Issuer } from 'openid-client'
async function setupOIDC(issuerUrl: string, clientId: string, clientSecret: string) {
const issuer = await Issuer.discover(issuerUrl)
console.log('Authorization Endpoint:', issuer.metadata.authorization_endpoint)
console.log('Token Endpoint:', issuer.metadata.token_endpoint)
console.log('UserInfo Endpoint:', issuer.metadata.userinfo_endpoint)
console.log('JWKS URI:', issuer.metadata.jwks_uri)
const client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: ['https://myapp.example.com/callback'],
response_types: ['code'],
})
return client
}
토큰 로테이션 전략
Refresh Token Rotation
Refresh Token Rotation은 탈취된 토큰이 재사용되는 것을 탐지하기 위한 핵심 보안 메커니즘입니다.
[정상 흐름]
Client → POST /token (grant_type=refresh_token, refresh_token=RT_1)
Server → 새 AT_2 + 새 RT_2 발급, RT_1 무효화
[탈취 시나리오 — Automatic Reuse Detection]
공격자가 RT_1을 탈취 후 사용:
공격자 → POST /token (refresh_token=RT_1) ← 이미 사용된 RT!
Server → RT_1이 이미 사용됨을 감지
→ RT_1의 전체 토큰 패밀리(RT_2, RT_3 ...) 모두 무효화
→ 사용자 강제 재로그인
// Node.js / Express: Refresh Token Rotation 구현
import { randomUUID } from 'crypto'
import Redis from 'ioredis'
const redis = new Redis()
interface TokenFamily {
userId: string
familyId: string
usedTokens: Set<string>
}
async function rotateRefreshToken(currentRefreshToken: string) {
const tokenData = await redis.get(`rt:${currentRefreshToken}`)
if (!tokenData) {
throw new Error('INVALID_REFRESH_TOKEN')
}
const parsed = JSON.parse(tokenData)
const familyKey = `family:${parsed.familyId}`
// Reuse Detection: 이미 사용된 토큰이면 전체 패밀리 무효화
const isUsed = await redis.sismember(`${familyKey}:used`, currentRefreshToken)
if (isUsed) {
console.warn(`[SECURITY] Token reuse detected! Family: ${parsed.familyId}`)
await revokeTokenFamily(parsed.familyId)
throw new Error('TOKEN_REUSE_DETECTED')
}
// 현재 RT를 "사용됨"으로 표시
await redis.sadd(`${familyKey}:used`, currentRefreshToken)
// 새 토큰 발급
const newRefreshToken = randomUUID()
const newAccessToken = generateJWT(parsed.userId)
await redis.setex(
`rt:${newRefreshToken}`,
7 * 24 * 3600,
JSON.stringify({
userId: parsed.userId,
familyId: parsed.familyId,
createdAt: Date.now(),
})
)
// 기존 RT 삭제
await redis.del(`rt:${currentRefreshToken}`)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
async function revokeTokenFamily(familyId: string) {
const members = await redis.smembers(`family:${familyId}:used`)
const pipeline = redis.pipeline()
for (const token of members) {
pipeline.del(`rt:${token}`)
}
pipeline.del(`family:${familyId}:used`)
await pipeline.exec()
}
Access Token 갱신 타이밍
| 전략 | 방식 | 장점 | 단점 |
|---|---|---|---|
| Proactive (사전 갱신) | 만료 30~60초 전에 미리 갱신 | 사용자 경험에 끊김 없음 | 불필요한 갱신 가능 |
| Reactive (반응 갱신) | 401 응답 수신 시 갱신 후 재시도 | 구현 단순 | 첫 요청 실패 + 지연 발생 |
| Hybrid | 타이머 기반 사전 갱신 + 401 폴백 | 두 장점 결합 | 구현 복잡도 증가 |
// Axios 인터셉터: Hybrid 갱신 전략
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
const api = axios.create({ baseURL: '/api', withCredentials: true })
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 이미 갱신 중이면 큐에 추가
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(() => api(originalRequest))
}
originalRequest._retry = true
isRefreshing = true
try {
await axios.post('/api/auth/refresh', {}, { withCredentials: true })
failedQueue.forEach(({ resolve }) => resolve())
failedQueue = []
return api(originalRequest)
} catch (refreshError) {
failedQueue.forEach(({ reject }) => reject(refreshError))
failedQueue = []
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
쿠키/JWT 하이브리드 패턴 상세
실전에서 권장하는 저장 전략은 다음과 같습니다.
| 데이터 | 저장 위치 | 이유 |
|---|---|---|
| Session ID | HttpOnly Secure 쿠키 | JS 접근 불가, 자동 전송 |
| Access Token | BFF 서버 메모리/Redis | 브라우저 노출 방지 |
| Refresh Token | BFF 서버 Redis (암호화) | 장기 토큰은 반드시 서버 보관 |
| CSRF Token | non-HttpOnly 쿠키 또는 헤더 | JS에서 읽어서 헤더에 포함 |
실전 Set-Cookie 설정:
// Express.js: 세션 쿠키 설정 (BFF)
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()
app.use(
session({
store: new RedisStore({ client: redisClient }),
name: '__Host-session', // __Host- 접두사: Secure + 특정 도메인에 고정
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS만 전송
sameSite: 'lax', // CSRF 방어: 외부 사이트에서 전송 차단
maxAge: 24 * 60 * 60 * 1000, // 24시간
path: '/',
// domain 생략 → 현재 호스트에만 적용 (__Host- 접두사와 함께)
},
})
)
// Set-Cookie 헤더 결과 예시:
// Set-Cookie: __Host-session=s%3Aabc123...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
브라우저 저장소별 접근 가능성 표
| 저장소 | JS 접근 | 서버 자동 전송 | XSS 취약 | CSRF 취약 | 권장 용도 |
|---|---|---|---|---|---|
| HttpOnly 쿠키 | 불가 | 자동 (same-origin) | 안전 | 취약 (SameSite로 방어) | Session ID, Refresh Token |
| 일반 쿠키 | 가능 | 자동 | 취약 | 취약 | CSRF Token (읽기용) |
| localStorage | 가능 | 수동 | 취약 | 안전 | 비민감 설정값 |
| sessionStorage | 가능 | 수동 | 취약 | 안전 | 탭별 임시 데이터 |
| 메모리 (변수) | 가능 | 수동 | 비교적 안전 | 안전 | SPA의 Access Token |
핵심 원칙: 민감한 토큰은 HttpOnly 쿠키 또는 서버 세션에 저장하고, JavaScript에서 직접 토큰을 다루지 마십시오.
CORS + 멀티 도메인 SSO
서브도메인 간 쿠키 공유
# Domain 속성을 이용한 서브도메인 쿠키 공유
Set-Cookie: session=abc; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=Lax
→ app1.example.com, app2.example.com, admin.example.com 모두에서 쿠키 전송
Cross-origin credential 전송
// 프론트엔드: credentials: 'include' 설정 필수
fetch('https://api.example.com/data', {
method: 'GET',
credentials: 'include', // 쿠키를 cross-origin으로 전송
})
// 백엔드: CORS 설정 (Express)
app.use(
cors({
origin: 'https://app.example.com', // '*' 사용 불가 (credentials 사용 시)
credentials: true, // Access-Control-Allow-Credentials: true
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
})
)
Third-party Cookie 제한과 대응
주요 브라우저들이 third-party cookie를 차단하거나 제한하고 있습니다. SameSite=None; Secure를 설정해야 cross-site 전송이 가능하지만, 이마저도 점점 제한되고 있습니다.
대응 전략:
- 모든 서비스를 같은 도메인(서브도메인)으로 통합
- Token-based SSO로 전환 (쿠키 대신 URL 파라미터 또는 postMessage 활용)
- BFF를 통한 프록시 패턴 사용
로그아웃 전략
로컬 로그아웃
// Express: 로컬 로그아웃 (세션 + 쿠키 삭제)
app.post('/logout', async (req, res) => {
const sessionData = req.session
// 1. IdP의 토큰을 revoke (선택적이지만 권장)
if (sessionData?.refreshToken) {
await fetch(`${ISSUER}/protocol/openid-connect/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: sessionData.refreshToken,
token_type_hint: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
}
// 2. 세션 삭제
req.session.destroy((err) => {
if (err) console.error('Session destroy error:', err)
// 3. 쿠키 삭제
res.clearCookie('__Host-session', { path: '/', httpOnly: true, secure: true })
res.json({ success: true })
})
})
SSO 글로벌 로그아웃
OIDC end_session_endpoint:
// 사용자를 IdP의 로그아웃 페이지로 리다이렉트
const logoutUrl = new URL(`${ISSUER}/protocol/openid-connect/logout`)
logoutUrl.searchParams.set('id_token_hint', idToken)
logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com')
res.redirect(logoutUrl.toString())
Back-Channel Logout (IdP → 각 서비스 직접 호출):
// POST /backchannel-logout 엔드포인트 (각 서비스에 구현)
app.post('/backchannel-logout', async (req, res) => {
const { logout_token } = req.body
// logout_token (JWT) 검증
const decoded = await verifyLogoutToken(logout_token)
const userId = decoded.sub
const sessionId = decoded.sid
// 해당 사용자의 모든 세션 무효화
await redis.del(`user_sessions:${userId}`)
console.log(`[Back-Channel Logout] User ${userId} session ${sessionId} invalidated`)
res.sendStatus(200)
})
Front-Channel Logout (IdP → iframe으로 각 서비스의 로그아웃 URL 호출):
<!-- IdP가 렌더링하는 로그아웃 페이지에 각 RP의 로그아웃 iframe 삽입 -->
<iframe src="https://app1.example.com/logout?sid=xxx" width="0" height="0"></iframe>
<iframe src="https://app2.example.com/logout?sid=xxx" width="0" height="0"></iframe>
참고: Back-Channel Logout이 Front-Channel보다 신뢰성이 높습니다. Front-Channel은 브라우저 종료 시 동작하지 않으며, third-party cookie 제한의 영향을 받습니다.
토큰 무효화 및 블랙리스트
Stateless JWT의 무효화 한계
JWT는 서명만으로 검증 가능한 self-contained 토큰이므로, 발급 후 만료 전까지 서버에서 무효화할 수 없습니다. 이것이 JWT의 본질적 한계입니다.
Redis 기반 블랙리스트
// JWT 블랙리스트 미들웨어
async function jwtBlacklistMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.sendStatus(401)
// 블랙리스트 확인 (jti 또는 토큰 해시)
const decoded = jwt.decode(token) as { jti: string; exp: number }
const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`)
if (isBlacklisted) {
return res.status(401).json({ error: 'Token has been revoked' })
}
// 정상 JWT 검증
try {
req.user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] })
next()
} catch {
return res.sendStatus(401)
}
}
// 블랙리스트 등록 (로그아웃 또는 관리자 강제 무효화)
async function revokeToken(token: string) {
const decoded = jwt.decode(token) as { jti: string; exp: number }
const ttl = decoded.exp - Math.floor(Date.now() / 1000)
if (ttl > 0) {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1')
}
}
Token Introspection (RFC 7662)
Opaque 토큰을 사용할 때, 리소스 서버가 Authorization Server에 토큰 유효성을 직접 질의하는 방식입니다.
POST /token/introspect HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=abc123opaque_token
→ 응답:
{
"active": true,
"sub": "user-1234",
"scope": "openid profile",
"client_id": "my-app",
"exp": 1709913600
}
실전 권장 조합: Short-lived Access Token (5~15분) + Refresh Token Rotation으로 블랙리스트 없이도 실질적 무효화 효과를 얻을 수 있습니다. AT가 짧으면 탈취되어도 빠르게 만료되고, RT는 로테이션으로 재사용을 탐지합니다.
멀티 서비스 인증 (마이크로서비스)
API Gateway에서 JWT 검증
# Kong Gateway: JWT 검증 플러그인 설정
plugins:
- name: jwt
config:
uri_param_names: []
header_names: ['Authorization']
claims_to_verify:
- exp
key_claim_name: iss
secret_is_base64: false
// Go: API Gateway JWT 검증 미들웨어
func JWTMiddleware(jwksURL string) func(http.Handler) http.Handler {
keySet, _ := jwk.Fetch(context.Background(), jwksURL)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
token, err := jwt.Parse([]byte(tokenStr), jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithAudience("my-api"),
)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user", token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Audience (aud) claim 활용
서비스 간 토큰 전파 시 aud 클레임으로 토큰의 의도된 수신자를 제한합니다.
사용자 → BFF: aud=["bff-service"]
BFF → User Service: aud=["user-service"] (토큰 교환 또는 새 토큰 발급)
BFF → Order Service: aud=["order-service"]
각 서비스는 자신의 aud 값이 토큰에 포함되어 있는지 반드시 검증해야 합니다. 이를 통해 User Service용 토큰이 Order Service에서 악용되는 것을 방지합니다.
Service Mesh (Istio) mTLS
서비스 간 통신은 Istio의 mTLS로 전송 계층 보안을 확보하고, JWT는 애플리케이션 계층 인가에 사용합니다.
# Istio: RequestAuthentication + AuthorizationPolicy
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-auth
spec:
jwtRules:
- issuer: 'https://idp.example.com'
jwksUri: 'https://idp.example.com/.well-known/jwks.json'
forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
spec:
rules:
- from:
- source:
requestPrincipals: ['*']
when:
- key: request.auth.claims[aud]
values: ['order-service']
보안 트레이드오프 종합
| 위협 | 쿠키 전용 | JWT 전용 (localStorage) | 하이브리드 (BFF) |
|---|---|---|---|
| XSS | HttpOnly로 안전 | 매우 취약 (토큰 탈취 가능) | HttpOnly 쿠키로 안전 |
| CSRF | 취약 (SameSite로 완화) | 안전 (쿠키 미사용) | SameSite + CSRF 토큰으로 방어 |
| Token Theft | 세션 ID만 노출 | AT+RT 모두 노출 위험 | 토큰 서버에만 존재 |
| Replay Attack | 세션 바인딩 | AT 탈취 시 재사용 가능 | Short-lived AT + 서버 검증 |
| Session Fixation | 로그인 후 세션 재생성으로 방어 | 해당 없음 | 세션 재생성으로 방어 |
| 토큰 즉시 무효화 | 세션 삭제로 가능 | 불가능 (만료까지 대기) | 세션+블랙리스트로 가능 |
Defense in Depth 원칙: 단일 방어선에 의존하지 않습니다. HttpOnly 쿠키 + SameSite + CSRF Token + CSP(Content Security Policy) + Short-lived Token + Token Rotation을 겹겹이 적용합니다.
프레임워크별 요약 비교표
| 항목 | Spring Boot | Django | React (SPA) | Next.js (App Router) |
|---|---|---|---|---|
| 토큰 저장 | 서버 세션 (HttpSession) | 서버 세션 (DB/Redis) | 메모리 변수 | 서버 세션 (NextAuth) |
| JWT 검증 | spring-security-oauth2-resource-server | PyJWT / djangorestframework-simplejwt | 불필요 (BFF 패턴 시) | jose 라이브러리 (서버 사이드) |
| 미들웨어 | SecurityFilterChain | DjangoMiddleware | Axios 인터셉터 | middleware.ts |
| CSRF 방어 | CsrfFilter (자동) | CsrfViewMiddleware (자동) | 쿠키 미사용 시 불필요 | CSRF Token 수동 구현 |
| 로그아웃 | OidcClientInitiatedLogoutSuccessHandler | mozilla-django-oidc logout view | 메모리 토큰 삭제 | signOut() + IdP 로그아웃 |
| 세션 저장소 | Redis (Spring Session) | Redis (django-redis) | 해당 없음 | Redis / DB |
통합 체크리스트
SSO + 하이브리드 인증 구현 시 반드시 확인해야 할 항목입니다.
- OIDC Discovery endpoint를 통한 IdP 설정 자동 로드
- Authorization Code Flow + PKCE 적용
- ID Token 검증:
iss,aud,exp,nonce클레임 확인 - Access Token은 브라우저에 노출하지 않음 (BFF 패턴)
- Refresh Token은 HttpOnly Secure 쿠키 또는 서버 세션에 저장
- Refresh Token Rotation 활성화 + Reuse Detection 구현
- Access Token 수명 15분 이하로 설정
- 세션 쿠키:
HttpOnly,Secure,SameSite=Lax,__Host-접두사 - CSRF 방어: SameSite 쿠키 + Double Submit Cookie 또는 Synchronizer Token
- CORS 설정:
credentials: true시 origin 명시적 지정 (*사용 금지) - JWKS 캐싱 및 자동 로테이션 대응
- 글로벌 로그아웃 (Back-Channel Logout 권장)
- Token Introspection 또는 블랙리스트를 통한 즉시 무효화
- 서비스 간 토큰 전파 시
aud클레임 검증 - CSP(Content-Security-Policy) 헤더 설정
- HTTPS 전용 (HTTP 접근 차단)
- 로그인 실패 시 Rate Limiting 적용
- 민감 로그 마스킹 (토큰 값을 로그에 남기지 않음)
흔한 버그와 오해
1. "OAuth 2.0은 인증 프로토콜이다" — 아닙니다
OAuth 2.0은 인가(Authorization) 프레임워크입니다. 사용자가 누구인지를 표준적으로 알려주지 않습니다. 인증이 필요하면 OIDC를 사용해야 합니다. Access Token만으로 "로그인"을 구현하면 보안 허점이 발생합니다.
2. "JWT는 항상 stateless하다" — 현실은 다릅니다
이론적으로 JWT는 stateless하지만, 실제 프로덕션에서는 블랙리스트, 세션 저장소, Token Introspection 등 stateful 요소가 거의 필수적입니다. "Pure Stateless JWT"는 즉시 무효화가 불가능하여 보안 사고 대응이 어렵습니다.
3. Access Token을 localStorage에 저장해도 된다?
절대 하지 마십시오. XSS 공격 한 번으로 토큰이 탈취됩니다. BFF 패턴을 사용하여 토큰을 서버에 보관하고, 브라우저에는 HttpOnly 세션 쿠키만 전달하십시오.
4. Refresh Token에 긴 만료 시간만 설정하면 안전하다?
Refresh Token은 탈취 시 장기간 악용될 수 있습니다. 반드시 Token Rotation + Reuse Detection을 적용하고, 가능하면 디바이스/IP 바인딩을 추가하십시오.
5. CORS 설정에서 Access-Control-Allow-Origin: *을 사용한다?
credentials: true와 함께 와일드카드(*)를 사용하면 브라우저가 요청을 거부합니다. 정확한 origin을 명시해야 합니다. 그뿐 아니라, 와일드카드 origin은 보안상 위험합니다.
6. SameSite=Strict로 설정하면 가장 안전하다?
SameSite=Strict는 외부 사이트에서 링크를 통해 진입할 때도 쿠키를 보내지 않아, SSO 사용자 경험을 심각하게 해칩니다. 대부분의 경우 SameSite=Lax가 적절합니다.
7. ID Token을 API 호출에 사용한다?
ID Token은 클라이언트 앱이 사용자 정보를 확인하기 위한 것입니다. API 호출에는 반드시 Access Token을 사용하십시오. ID Token의 aud는 클라이언트 앱이고, API 서버가 아닙니다.
참고자료
- OpenID Connect Core 1.0 Specification
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - OAuth 2.0 Bearer Token Usage
- RFC 7519 - JSON Web Token (JWT)
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- OpenID Connect RP-Initiated Logout
- OpenID Connect Back-Channel Logout
- Keycloak Documentation - Securing Applications
- Auth0 - Token Best Practices
- Auth0 - Refresh Token Rotation
- OWASP - Session Management Cheat Sheet
- OWASP - JSON Web Token Cheat Sheet
- Spring Security OAuth2 Resource Server Reference
- NextAuth.js Documentation
- Mozilla Django OIDC Documentation
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- Istio Security - Request Authentication