- Published on
SSO統合実践ガイド — OIDC/OAuth2 + Cookie/JWTハイブリッドアーキテクチャ、トークンローテーション完全攻略
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 概要 — SSOとハイブリッド認証アーキテクチャ
- OIDC/OAuth2プロトコル詳解
- ハイブリッドアーキテクチャ設計
- SSO実装実践
- トークンローテーション戦略
- Cookie/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
なぜハイブリッド(Cookie+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)
}
)
Cookie/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
サブドメイン間のCookie共有
# Domain 속성을 이용한 서브도메인 쿠키 공유
Set-Cookie: session=abc; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=Lax
→ app1.example.com, app2.example.com, admin.example.com 모두에서 쿠키 전송
Cross-originクレデンシャル送信
// 프론트엔드: 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)クレームの活用
서비스 간 토큰 전파 시 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']
セキュリティトレードオフ総合
| 脅威 | Cookie専用 | 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