당신의 마이크로서비스는 VPC를 공유하므로 서로를 신뢰한다. 이 신뢰는 주변 권한이다. 서비스를 호출할 수 있는 권한은 암호학적 증명이 아닌 네트워크 토폴로지에 의해 부여된다. 경계를 침범한 공격자는 이 모든 권한을 그대로 물려받는다.

서비스는 주변 권한 없이도 충분히 인증할 수 있다. 더 어려운 질문은 당신의 플랫폼이 왜 이것을 배신처럼 느끼게 만드는가이다.

프로덕션에서 주변 권한의 실체

대부분의 서비스 간 “인증”은 사실 네트워크 분할에 불과하다. 서비스는 사설 서브넷에 있거나 API 게이트웨이 뒤에 있거나, 서비스 메시를 갖춘 쿠버네티스 클러스터 낤부에 존재한다. 가정은 단순하다. 요청이 낤부 IP에서 온다면 그것은 정당한 것이다.

이 모델은 경계가 무너지면 함께 무너진다. 침해된 파드, 자격 증명 유출 이후의 횡적 이동, 공급망 주입은 공격자를 신뢰 구역 낤부로 밀어넣는다. 그 순간 모든 서비스는 접근 가능해지고 대부분은 신원 증명을 요구하지 않는다. 공격자는 더 많은 자격 증명을 훔칠 필요가 없다. 그들은 단순히 네트워크의 주변 권한을 물려받을 뿐이다.

서비스 간 진정한 인증은 각 호출자가 자신이 누구인지 증명하고, 각 피호출자가 그 증명을 검증하는 것을 의미한다. 네트워크는 단지 전송 수단일 뿐이다. 그것은 자격 증명이 되어서는 안 된다.

권능 기반 서비스 인증의 작동 원리

실용적인 접근법은 두 가지다. 암호학적 신원(모든 서비스가 증명 가능한 이름을 갖는다)과 권능 토큰(모든 요청이 위조 불가능한 범위가 제한된 권한을 지닌다)이다.

mTLS를 통한 암호학적 신원

상호 TLS에서는 클라이언트와 서버 모두 X.509 인증서를 제시한다. 서버는 요청을 처리하기 전 클라이언트의 인증서를 검증한다. 클라이언트는 데이터를 보내기 전 서버의 인증서를 검증한다. 어느 쪽도 IP 주소 때문에 상대를 신뢰하지 않는다.

인프라의 과제는 인증서 배포다. SPIFFE와 SPIRE는 이를 해결하기 위해 모든 워크로드에 위치가 아닌 속성에서 파생된 암호학적 신원을 부여한다. payments-api를 실행하는 파드는 spiffe://prod.example.com/payments-api와 같은 SPIFFE ID를 받는다. 인증서는 수명이 짧고 자동으로 교체된다.

Go에서의 모습은 다음과 같다:

package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "log"
    "net/http"
    "os"
)

// loadClientTLS returns a config that verifies the server AND presents a client cert.
func loadClientTLS(caCert, clientCert, clientKey []byte) *tls.Config {
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caCert)

    cert, err := tls.X509KeyPair(clientCert, clientKey)
    if err != nil {
        log.Fatalf("failed to load client cert: %v", err)
    }

    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      pool,
        // Do not skip verification based on IP or DNS name alone.
        InsecureSkipVerify: false,
    }
}

func main() {
    caCert, _ := os.ReadFile("ca.crt")
    clientCert, _ := os.ReadFile("service.crt")
    clientKey, _ := os.ReadFile("service.key")

    client := &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: loadClientTLS(caCert, clientCert, clientKey),
        },
    }

    resp, err := client.Get("https://orders-api.internal:8443/orders")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}

핵심 줄은 InsecureSkipVerify: false다. 이것이 없으면 당신은 다시 주변 신뢰로 되돌아간다. 이 설정이 있으면 클라이언트는 서버가 CA가 서명한 유효한 인증서를 제시하지 않는 한 연결을 거부한다. 서버도 클라이언트에 대해 동일하게 행동한다.

범위 제한 권한을 위한 권능 토큰

인증서는 신원을 증명한다. 권능은 권한을 증명한다. 많은 시스템에서 둘 다 필요하다. 먼저 당신이 누구인지 증명하고, 그다음 이 특정 행위가 허용되었음을 증명한다.

권능 토큰은 신원, 행위, 자원을 위조 불가능한 패키지로 묶는다. 서비스가 이를 발행한다. 서비스가 이를 검증한다. 중앙 세션 저장소를 조회할 필요가 없다.

import hmac
import hashlib
import secrets
import time

SECRET = secrets.token_bytes(32)

def mint_service_capability(
    caller_id: str,
    service: str,
    action: str,
    resource: str,
    ttl_seconds: int = 300
) -> str:
    """Mint a time-bound capability for a specific service action."""
    expires = int(time.time()) + ttl_seconds
    payload = f"{caller_id}:{service}:{action}:{resource}:{expires}"
    sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return f"{payload}:{sig}"

def verify_service_capability(
    token: str,
    expected_service: str,
    expected_action: str,
    expected_resource: str
) -> bool:
    """Verify a capability token is unexpired and correctly scoped."""
    try:
        caller_id, svc, action, resource, expires, sig = token.rsplit(":", 5)
    except ValueError:
        return False

    if int(expires) < time.time():
        return False
    if svc != expected_service or action != expected_action or resource != expected_resource:
        return False

    payload = f"{caller_id}:{svc}:{action}:{resource}:{expires}"
    expected_sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return hmac.compare_digest(sig, expected_sig)

# Service A requests a capability to call Service B.
token = mint_service_capability("payments-api", "orders-api", "read", "order-123")

# Service B verifies the capability before handling the request.
if not verify_service_capability(token, "orders-api", "read", "order-123"):
    raise PermissionError("Invalid or expired capability")

토큰은 자급적이고 수명이 짧다. 서비스 B는 권한 부여 서버를 호출할 필요가 없다. 단지 HMAC과 만료 시간을 확인할 뿐이다. 범위는 명시적이다. 이 토큰은 orders-api에서 order-123을 읽는 것에만 유효하다.

왜 이것이 예상보다 어려운가

기술은 존재한다. 어려움은 운영에 있다.

신뢰의 부트스트래핑이 첫 번째 문제다. 모든 서비스에 인증서가 필요하다면 무언가가 그 인증서를 발행해야 한다. 그것, 즉 인증 기관이나 SPIRE 서버는 새로운 공격 대상이 된다. 당신은 신뢰를 제거한 것이 아니다. 집중시킨 것이다.

폐기 문제도 있다. 서비스가 침해되면 어떻게 그 신원을 무효화하는가? mTLS 인증서는 수명이 짧아 이에 도움이 되지만, 특정 인증서를 폐기하려면 여전히 인증서 폐기 목록을 배포하거나 OCSP를 사용해야 한다. 권능 토큰은 빠르게 만료되어 피해를 제한하지만, 서명 키가 훔쳐지면 공격자가 무제한의 토큰을 발행할 수 있다.

성능도 우려 사항이다. TLS 핸드셰이크는 지연 시간을 추가한다. 사이드카를 갖춘 서비스 메시에서 오버헤드는 보통 허용 가능하지만(수 밀리초), 고처리량 경로에서는 그 밀리초들이 누적된다. 권능 토큰은 인증 서버로의 왕복을 피하지만, 규모가 커지면 HMAC 검증도 공짜가 아니다.

주변 권한이 여전히 타당한 곳

모든 낤부 호출이 암호학적 증명을 필요로 하는 것은 아니다. 데이터베이스 클라이언트와 동일한 컨테이너 낤부에서 실행되는 크론 작업은 로컬호스트와 통신하기 위해 mTLS가 필요 없다. 부하 분산기와 노드 사이의 상태 확인에는 권능 토큰이 필요 없다.

목표는 주변 권한의 완전한 제거가 아니다. 목표는 당신의 주변 권한이 어디에 존재하는지, 그 영향 범위가 수용 가능한지 아는 것이다. 낤부 인증 없이 200개의 서비스가 공유하는 VPC는 크고 보이지 않는 영향 범위다. 파일 권한 0600을 가진 로컬 유닉스 소켓은 작고 명시적인 영향 범위다.

서비스 호출에서 주변 권한을 제거하는 방법

첫날부터 서비스 메시가 필요한 것은 아니다. 민감한 데이터를 다루거나 신뢰 경계에 위치한 서비스부터 시작하라.

첫째, 대리자를 파악하라. 어떤 서비스가 다른 서비스가 갖지 않은 권한을 가지고 있는가? 잘못된 서비스에 의해 호출되면 위험한 낤부 API는 무엇인가? 그것들이 후보다.

둘째, API 계층에 mTLS를 추가하라. Envoy나 NGINX 같은 역방향 프록시를 사용 중이라면 애플리케이션 코드를 변경하지 않고도 거기서 mTLS를 종료할 수 있다. 프록시는 검증된 클라이언트 신원이 담긴 헤더를 업스트림으로 전달한다. 업스트림은 그 헤더를 확인한다.

셋째, 토큰의 범위를 좁혀라. 이미 서비스 인증에 JWT를 사용 중이라면 페이로드에 role: service를 넣는 것을 멈춰라. allowed_services: ["orders-api"]allowed_resources: ["order:*"]를 넣어라. 권한 부여를 해당 작업만큼 구체적으로 만들어라.

주변 권한 없는 서비스 인증에 관한 자주 묻는 질문

mTLS만으로 충분한가, 아니면 권능도 필요한가?

mTLS는 신원을 증명한다. 권능은 권한을 증명한다. 둘은 서로 다른 문제를 해결한다. 유효한 인증서를 가진 서비스조차 임의의 데이터를 삭제하도록 허용되어서는 안 된다. 전송 보안과 신원에는 mTLS를, 접근 제어에는 권능을 사용하라.

Istio나 Linkerd 같은 서비스 메시는 어떤가?

서비스 메시는 파드 간 mTLS와 신원을 자동화한다. 이것은 모든 서비스를 다시 작성하지 않고도 주변 권한을 제거하는 실용적인 방법이다. 메시는 인증서 교체, 신원 증명, 트래픽 암호화를 처리한다. 트레이드오프는 복잡성과 새로이 보호해야 할 컨트롤 플레인이다.

서버리스 함수에도 적용되는가?

그렇다. 하지만 더 어렵다. 서버리스 함수는 일시적이므로 인증서 교체와 신원 증명은 호출 시점에 이루어져야 한다. 클라우드 제공업체는 이를 위해 워크로드 신원을 제공한다(AWS IAM Roles for Service Accounts, GCP Workload Identity). 이것들은 권능과 유사하다. 함수는 포괄적인 자격 증명이 아닌, 허용된 작업의 범위로 제한된 토큰을 받는다.

서비스 계정과 권능의 차이는 무엇인가?

서비스 계정은 기본적으로 주변 권한을 지닌 신원이다. 서비스 A가 payments-sa로 실행된다면, 보통 payments-sa가 할 수 있는 모든 것을 할 수 있다. 권능은 특정 자원에 대한 특정 행위로 범위가 제한된다. 권능이 훔쳐지더라도 다른 대상에 대해 재사용될 수 없다.

이미 가지고 있는 경계에서 시작하라

당신의 VPC는 결코 보안 경계가 아니었다. 그것은 우리가 보안 경계인 것처럼 가장한 네트워크 경계였다. 주변 권한을 제거한다는 것은 모든 서비스 호출을 그 경계를 넘는 것처럼 대우하는 것을 의미한다. 암호학적 신원과 범위가 제한된 권능이 도구다. 어려운 부분은 낡은 모델이 보안으로 위장한 편의성이었다는 것을 인정하는 것이다.

하나의 낤부 API를 고른다. 클라이언트 인증서 검증을 추가한다. 무엇이 망가지는지 지켜본다. 대부분의 경우, 망가지는 것은 당신의 코드가 알지 못한 채 하고 있던 가정이다.