API Gateway에 외/내부 토큰 적용기

FIRE-918·2025년 9월 22일

마이크로서비스 아키텍처에서 Gateway를 통한 인증/인가 구현 과정에서 겪었던 보안 이슈들과 해결 방안을 단계별로 정리해보겠습니다.


가장 처음 시도했던 방식

처음에는 가장 직관적인 방식으로 Gateway에서 JWT 토큰을 검증한 뒤 사용자 정보를 HTTP 헤더에 담아 백엔드 서비스로 전달하는 방식을 택했습니다.

Client → Gateway (JWT 검증) → Backend Service
                ↓
            X-User-Id: 12345
            X-School-Id: 67890
            X-Authority: STUDENT

이 방식은 빠르게 구현할 수 있었지만 보안 문제는 고려하지 못한 채로 진행했습니다


발견된 문제점
구현을 완료하고 테스트해보니 치명적인 보안 취약점을 발견했습니다

# 공격자가 Gateway를 우회하여 직접 호출 가능
curl -H "X-User-Id: 99999" \
     -H "X-School-Id: 11111" \
     -H "X-Authority: ADMIN" \
     http://backend-service/api/admin/users

Gateway를 거치지 않고도 백엔드 서비스에 직접 접근할 수 있었고, 임의의 사용자 정보를 헤더에 넣어서 권한 상승이 가능한 상황이었습니다

Gateway가 필수적인 관문이 되지 못하는 심각한 문제였습니다


다음으로 선택한 방법
이를 보완하기 위해 Gateway에서만 붙일 수 있는 인증 토큰(X-Gateway-Token)을 추가했습니다

Client → Gateway (JWT 검증) → Backend Service
                ↓
            X-User-Id: 12345
            X-School-Id: 67890
            X-Authority: STUDENT
            X-Gateway: Gateway # 예시 코드입니다.

하지만 이 역시 정적인 토큰이 탈취되면 그대로 악용될 수 있었습니다

# 여전히 조작 가능한 보안 이슈
curl -H "X-User-Id: 99999" \
     -H "X-Gateway: Gateway" \
     http://backend-service/api/admin/users

이 방법도 근본적인 해결책은 되지 못했습니다


최종 해결 (외부 내부 토큰을 적용한 인증/인가)
팀원이 공유해준 글을 읽던 중 외부 토큰 + 내부 토큰 조합을 사용하는 해결책을 발견했습니다

구현 플로우

    A[Client] -->|JWT Token| B[Gateway]
    B -->|Token 검증| B
    B -->|User 존재 확인| C[Auth Service]
    C -->|Passport 발급| B
    B -->|Passport 전달| D[Backend Services]
    D -->|Passport 검증| D

단계 별 처리 과정

  • Gateway에서의 처리
// JWT 토큰 유효성 검사 (유효기간, 시크릿키)
if (jwtService.validateToken(token)) {
    // Auth Service로 사용자 검증 요청
    val user = authService.validateUser(userId)

    // Passport 발급
    val passport = passportService.generatePassport(user)

    // 백엔드 서비스로 라우팅
    routeWithPassport(passport)
}
  • Passport 구조
data class Passport(
    val user: PassportUser,
    val userIntegrity: String  // HMAC 기반 무결성 검증값
)

data class PassportUser(
    val id: UUID,
    val schoolId: UUID,
    val authority: Authority
)
  • 무결성 검증 로직
fun generateIntegrity(user: PassportUser): String {
    val data = "${user.id}|${user.schoolId}|${user.authority}"
    val hmac = Mac.getInstance("HmacSHA256")
    hmac.init(SecretKeySpec(secretKey, "HmacSHA256"))
    return Base64.getEncoder().encodeToString(hmac.doFinal(data.toByteArray()))
}

이 방식을 통해 Gateway를 반드시 거치지 않으면 Passport가 생성되지 않으므로 백엔드 서비스는 위조된 요청을 차단할 수 있게 되었습니다


추후 리팩토링을 하며

userId를 main의 DB에서 확인 후 인증하고 passport를 발급하며 main 서버가 장애가 생기면 gateway와 그 외의 서브 모듈에도 피해가 간다는 것을 깨달았습니다

저희 프로젝트 특성 상 없는 userId가 사용되는 일은 없었고, userId 조회를 제거하고 passport 발급을 main 모듈에서 하는 방향으로 리팩토링 하였습니다.

userId가 있는지 없는지 필수로 조회해야하는 프로젝트였다면 auth 모듈을 분리할 계획이었습니다.


회고
이번 과정을 통해 "보안은 구현 이후가 아니라 설계 단계에서부터 고려해야 한다" 는 중요한 교훈을 얻었습니다 앞으로도 Gateway뿐만 아니라 다른 서비스 전반에서도 보안 이슈를 사전에 검토하고 점진적으로 개선해 나가려 합니다

긴 글 읽어주셔서 감사합니다


구현 시에 참고한 글 입니다.

profile
학습한 내용을 기록하고 공유하며 함께 성장하는 백엔드 개발자입니다.

0개의 댓글