Skip to content
Published on

SSO 통합 실전 가이드 — OIDC/OAuth2 + 쿠키/JWT 하이브리드 아키텍처, 토큰 로테이션 완전 정복

Authors
  • Name
    Twitter

📚 SSO 쿠키/JWT 인증 시리즈 > Next.js 편 ← 현재: 통합 실전편 | 시리즈 인덱스

개요 — 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 TokenAPI 접근 권한 부여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은 탈취된 토큰이 재사용되는 것을 탐지하기 위한 핵심 보안 메커니즘입니다.

[정상 흐름]
ClientPOST /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!
  ServerRT_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 IDHttpOnly Secure 쿠키JS 접근 불가, 자동 전송
Access TokenBFF 서버 메모리/Redis브라우저 노출 방지
Refresh TokenBFF 서버 Redis (암호화)장기 토큰은 반드시 서버 보관
CSRF Tokennon-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를 차단하거나 제한하고 있습니다. 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"]
BFFUser Service:   aud=["user-service"]     (토큰 교환 또는 새 토큰 발급)
BFFOrder 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)
XSSHttpOnly로 안전매우 취약 (토큰 탈취 가능)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 BootDjangoReact (SPA)Next.js (App Router)
토큰 저장서버 세션 (HttpSession)서버 세션 (DB/Redis)메모리 변수서버 세션 (NextAuth)
JWT 검증spring-security-oauth2-resource-serverPyJWT / djangorestframework-simplejwt불필요 (BFF 패턴 시)jose 라이브러리 (서버 사이드)
미들웨어SecurityFilterChainDjangoMiddlewareAxios 인터셉터middleware.ts
CSRF 방어CsrfFilter (자동)CsrfViewMiddleware (자동)쿠키 미사용 시 불필요CSRF Token 수동 구현
로그아웃OidcClientInitiatedLogoutSuccessHandlermozilla-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 서버가 아닙니다.


참고자료

  1. OpenID Connect Core 1.0 Specification
  2. RFC 6749 - The OAuth 2.0 Authorization Framework
  3. RFC 6750 - OAuth 2.0 Bearer Token Usage
  4. RFC 7519 - JSON Web Token (JWT)
  5. RFC 7662 - OAuth 2.0 Token Introspection
  6. RFC 7636 - Proof Key for Code Exchange (PKCE)
  7. OpenID Connect RP-Initiated Logout
  8. OpenID Connect Back-Channel Logout
  9. Keycloak Documentation - Securing Applications
  10. Auth0 - Token Best Practices
  11. Auth0 - Refresh Token Rotation
  12. OWASP - Session Management Cheat Sheet
  13. OWASP - JSON Web Token Cheat Sheet
  14. Spring Security OAuth2 Resource Server Reference
  15. NextAuth.js Documentation
  16. Mozilla Django OIDC Documentation
  17. RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
  18. Istio Security - Request Authentication