> **📚 SSO 쿠키/JWT 인증 시리즈** > [Next.js 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-nextjs) ← 현재: 통합 실전편 | [시리즈 인덱스](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-series-index)
개요 — 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 생성
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
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)
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 구현
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 갱신 전략
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)
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 삽입 -->
> **참고:** 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 서버가 아닙니다.
참고자료
1. [OpenID Connect Core 1.0 Specification](https://openid.net/specs/openid-connect-core-1_0.html)
2. [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)
3. [RFC 6750 - OAuth 2.0 Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750)
4. [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
5. [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
6. [RFC 7636 - Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
7. [OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)
8. [OpenID Connect Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html)
9. [Keycloak Documentation - Securing Applications](https://www.keycloak.org/documentation)
10. [Auth0 - Token Best Practices](https://auth0.com/docs/secure/tokens/token-best-practices)
11. [Auth0 - Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)
12. [OWASP - Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
13. [OWASP - JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
14. [Spring Security OAuth2 Resource Server Reference](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html)
15. [NextAuth.js Documentation](https://next-auth.js.org/getting-started/introduction)
16. [Mozilla Django OIDC Documentation](https://mozilla-django-oidc.readthedocs.io/en/stable/)
17. [RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
18. [Istio Security - Request Authentication](https://istio.io/latest/docs/reference/config/security/request_authentication/)
현재 단락 (1/574)
**Single Sign-On(SSO)** 은 사용자가 하나의 자격 증명으로 여러 애플리케이션과 서비스에 접근할 수 있는 인증 메커니즘입니다. 한 번 로그인하면 같은 조직 내의 이...