콘텐츠로 이동

인증된 요청도 왜 다시 권한을 확인해야 할까

보안을 기능 목록으로 외우지 않고, 한 요청이 TLS·인증·인가·입력 검증·데이터 접근을 지날 때 무엇을 누구에게 믿을 수 있는지 확인합니다.

예상 읽기 시간 8분 · 핵심 질문: 신원은 확인했는데 왜 다른 사용자의 주문을 볼 수 있는가

사용자 A가 로그인한 뒤 /orders/100을 조회할 수 있다고 하자. 주소의 100101로 바꿨더니 사용자 B의 주문이 보인다면 로그인은 정상이어도 보안은 깨졌다. 서버는 “누구인가”는 확인했지만 “이 주문을 볼 권한이 있는가”를 확인하지 않았다.

서버 보안은 인증 기능 하나가 아니다. 신뢰할 수 없는 요청이 경계를 지날 때 신원·권한·입력·비밀을 어떤 근거로 검증하고, 실패 시 피해 범위를 어디까지 제한할지 정하는 일이다.

flowchart TB
  U["사용자 또는 서비스 요청"] --> T["TLS<br/>서버 신원 확인과 전송 보호"]
  T --> I["인증 정보 검증<br/>세션 또는 토큰"]
  I --> A["인가<br/>주체·자원·행동·현재 맥락 확인"]
  A --> V["입력 형식·크기·업무 규칙 검증"]
  V --> D["최소 권한으로 데이터 접근"]
  D --> O["출력 인코딩과 민감 정보 제거"]
  A -. "결정 근거" .-> L["감사 기록"]
  D -. "중요 변경" .-> L

인증은 주체를 세우고 인가는 행동을 결정한다

인증(Authentication)은 요청을 보낸 사용자가 누구인지 확인한다. 인가(Authorization)는 확인된 주체가 특정 자원에 특정 행동을 해도 되는지 결정한다. 인증에 성공했다고 모든 주문을 조회할 수 있는 것은 아니다.

인가 판단에는 주체, 자원, 행동과 맥락이 필요하다. 사용자 A가 주문 100의 소유자인지, 상담원 역할이라면 어느 조직의 주문까지 볼 수 있는지, 이미 취소된 주문에 추가 환불을 실행해도 되는지를 함께 본다. 경로의 역할 검사 하나로 끝내면 객체 식별자만 바꿔 다른 사용자의 데이터에 접근하는 문제가 남는다.

기본은 명시적으로 허용하지 않은 행동을 거절하는 기본 거부와 필요한 최소 권한만 부여하는 최소 권한이다. 역할에 권한을 묶는 RBAC(Role-Based Access Control)은 이해하기 쉽지만 예외가 쌓이면 역할이 폭증한다. 사용자·자원·시간 같은 속성을 조합하는 ABAC(Attribute-Based Access Control)는 세밀하지만 왜 허용됐는지 설명하고 검증하기 더 어렵다.

OAuth 2.0은 로그인 규격이 아니라 접근 권한 위임 규격이다

OAuth 2.0은 사용자가 제3자 애플리케이션에 자신의 비밀번호를 주지 않고 제한된 자원 접근 권한을 맡기기 위한 체계다. OIDC(OpenID Connect)는 OAuth 2.0 위에 사용자 로그인과 신원 확인 규칙을 더한다.

브라우저나 모바일 앱 같은 공개 클라이언트에서는 인가 코드 흐름과 PKCE(Proof Key for Code Exchange)를 사용해 중간에서 탈취한 코드만으로 토큰을 받기 어렵게 한다. 로그인 요청과 응답을 묶는 state, 재생된 신원 응답을 막는 nonce, 정확한 리디렉션 주소도 검증한다.

접근 토큰(access token)은 자원 서버에 권한을 제시한다. ID 토큰은 클라이언트가 로그인 결과와 사용자 신원을 확인하기 위한 것이다. ID 토큰을 API 접근 토큰처럼 혼용하지 않는다. 토큰을 받았다는 사실보다 발급자, 대상 서비스, 만료, 서명과 필요한 범위를 모두 검증해야 한다.

JWT와 서버 세션은 상태를 어디에서 확인하는지가 다르다

JWT(JSON Web Token)는 신원과 권한에 관한 주장(claim)을 압축된 형식으로 전달한다. 일반적인 서명 JWT의 본문은 암호화되지 않아 누구나 디코딩할 수 있다. 서명은 허가 없이 내용이 바뀌지 않았음을 확인할 뿐 비밀로 만들지 않는다.

자원 서버가 공개키로 JWT를 직접 검증하면 중앙 세션 조회를 줄일 수 있다. 대신 사용자가 로그아웃하거나 권한을 잃어도 이미 발급된 토큰은 만료 전까지 유효할 수 있다. 키 교체와 오래된 권한 주장도 운영해야 한다.

서버 세션은 브라우저에는 추측하기 어려운 세션 식별자만 주고 실제 상태를 중앙 저장소에서 조회한다. 조회 의존성은 생기지만 즉시 폐기와 민감 정보의 중앙 관리가 쉽다. JWT가 언제나 더 확장 가능하고 세션이 낡은 방식인 것이 아니다. 즉시 권한 회수가 중요한지, 인증 저장소 장애 때 어떤 동작을 원하는지로 고른다.

토큰 교체는 유출된 토큰의 사용 시간을 줄인다

접근 토큰은 짧게 유지하고 더 오래 사는 리프레시 토큰(refresh token)으로 새 접근 토큰을 받게 할 수 있다. 리프레시 토큰 교체는 사용할 때마다 새 값을 발급하고 이전 값을 폐기한다. 이미 폐기된 이전 토큰이 다시 나타나면 탈취 후 재사용됐을 가능성이 있으므로 같은 발급 계열을 모두 중단하고 재인증을 요구할 수 있다.

다만 서버가 새 토큰을 발급했는데 네트워크 응답만 유실되면 정상 클라이언트가 이전 토큰을 다시 보낼 수도 있다. 짧은 중복 허용 창, 이전·새 토큰의 계열 추적과 위험 기반 재인증 정책으로 실제 공격과 통신 실패를 구분한다.

결제 같은 중요한 작업은 토큰 만료만 믿지 않는다. 요청을 한 번만 쓰게 하는 일회용 값, 중복 실행을 막는 멱등성 키, 최근 재인증이나 토큰을 실제 소유했음을 증명하는 방식도 검토한다.

CSRF와 XSS는 브라우저의 서로 다른 성질을 악용한다

CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 브라우저가 쿠키 같은 인증 정보를 요청에 자동으로 붙이는 성질을 악용한다. 공격자 페이지가 로그인된 사용자의 브라우저로 송금 요청을 보내게 할 수 있다. SameSite 쿠키, 예측 불가능한 CSRF 토큰과 요청 출처 검증을 사용하고, 상태를 바꾸는 작업을 GET으로 만들지 않는다.

XSS(Cross-Site Scripting, 사이트 간 스크립팅)는 공격자의 입력이 신뢰받는 사이트에서 스크립트로 실행되는 문제다. HTML 본문, 속성, URL과 자바스크립트 문자열은 각각 안전한 인코딩 방식이 다르다. 입력을 한 번에 깨끗하게 만든다고 생각하기보다 출력되는 문맥에서 데이터가 코드로 해석되지 않게 한다. 콘텐츠 보안 정책(CSP)은 허용할 스크립트 출처를 제한해 피해를 줄인다.

CORS(Cross-Origin Resource Sharing)는 다른 출처의 스크립트가 응답을 읽도록 허용하는 브라우저 정책이다. 자격 증명이 붙은 요청 자체를 막는 CSRF 방어와 같지 않다.

SQL 주입은 데이터가 SQL 구조로 해석될 때 생긴다

사용자 입력을 SQL 문자열에 이어 붙이면 입력이 따옴표를 닫고 조건이나 명령을 추가할 수 있다. 준비된 문장과 매개변수 바인딩을 사용하면 SQL 구조와 데이터가 분리된다.

ORM(Object-Relational Mapping)을 쓴다는 사실만으로 모든 질의가 안전해지지는 않는다. 동적 네이티브 질의를 문자열로 만들거나 정렬 열 이름을 그대로 붙이면 주입 경로가 남는다. 열·표 이름처럼 매개변수로 바인딩할 수 없는 값은 서버가 정한 허용 목록에서 선택한다. 데이터베이스 계정도 필요한 표와 작업만 허용해 주입 성공 뒤 피해 범위를 줄인다.

비밀번호는 복호화할 값이 아니라 검증할 값이다

비밀번호 원문이나 복호화 가능한 암호문을 저장하지 않는다. 사용자마다 무작위 솔트를 붙이고 Argon2id, scrypt, bcrypt처럼 계산과 메모리 비용을 조절할 수 있는 느린 해시 함수를 사용한다. 데이터베이스가 유출돼도 공격자가 후보 비밀번호를 빠르게 대입하기 어렵게 만드는 목적이다.

비용을 무조건 높이면 로그인 서버가 서비스 거부 공격에 취약해질 수 있다. 현재 하드웨어에서 정상 로그인 지연과 공격 비용을 측정해 조절하고, 로그인 요청률 제한·유출 비밀번호 거부·다중 인증을 함께 사용한다. 별도 비밀인 pepper를 사용한다면 데이터베이스와 다른 비밀 관리 경계에서 보관하고 교체 절차를 정한다.

TLS와 비밀 관리는 서로 다른 구간을 보호한다

TLS(Transport Layer Security)는 서버 인증서를 검증하고 세션 키를 합의해 전송 중 기밀성과 무결성을 보호한다. 프록시나 CDN에서 TLS를 종료하면 그 지점은 평문 요청을 볼 수 있는 신뢰 경계가 된다. 프록시에서 원본 서버까지 다시 TLS를 적용할지, 인증서를 누가 발급하고 교체할지 정한다.

데이터베이스 비밀번호와 서명 키 같은 비밀은 소스 코드, 컨테이너 이미지와 로그에 넣지 않는다. 실행 시 검증 가능한 작업 부하 신원으로 필요한 비밀만 받아 사용하고, 접근 감사와 교체를 자동화한다. 비밀을 자주 교체해도 애플리케이션이 이전 값만 캐시하거나 무중단 전환을 못 하면 실제 교체가 아니다.

제로 트러스트는 내부 네트워크를 신원의 근거로 삼지 않는다

제로 트러스트는 “사내망 IP에서 왔으니 믿는다”는 가정을 버리고 요청마다 주체·자원·행동·맥락을 확인하는 원칙이다. 서비스 사이에서는 배포 위치나 IP보다 인증서나 서명 토큰으로 검증 가능한 작업 부하 신원(workload identity)을 사용한다.

mTLS(mutual TLS)는 클라이언트와 서버가 서로 인증서를 확인해 연결 상대의 서비스 신원을 증명할 수 있다. 하지만 결제 서비스라는 신원이 특정 사용자의 환불을 승인할 권한까지 자동으로 결정하지는 않는다. 신원 발급, 객체별 인가 정책, 인증서 수명과 교체, 감사 기록이 함께 있어야 한다.

보안 검토는 피해가 큰 경계부터 시작한다

먼저 보호할 자산과 신뢰 경계를 그린다. 계정 탈취, 다른 사용자 데이터 조회, 결제 중복, 관리자 권한 오용처럼 가능한 피해를 적고, 공격자가 통제할 수 있는 입력과 자격 증명을 표시한다. 그다음 예방뿐 아니라 탐지와 회복을 붙인다.

감사 기록에는 누가 어떤 자원에 어떤 행동을 요청했고 어떤 정책으로 허용·거절됐는지를 남긴다. 토큰 원문과 비밀번호 같은 비밀은 남기지 않는다. 권한을 회수한 뒤 캐시와 활성 세션에 언제 반영되는지, 키가 유출되면 어느 범위까지 폐기하고 다시 발급할지도 문서화한다.

좋은 보안 설계는 “JWT를 사용한다”가 아니라 다음처럼 경계와 실패 대응을 설명한다.

접근 토큰의 발급자·대상·서명·만료를 API 입구에서 검증하고, 주문 조회 시 현재 주체와 주문 소유자를 다시 비교한다. 접근 토큰은 짧게 유지하며 리프레시 토큰 재사용이 탐지되면 발급 계열을 폐기한다. 인가 결정은 감사하되 토큰 원문은 기록하지 않는다.

참고 자료