콘텐츠로 이동

Authentication vs Authorization

분류: Layer 1 - 백엔드 기초

첫 회독은 인증과 인가를 분리하고, 인가를 정책 입력과 결정으로 읽는 데만 집중한다. 아래 순서대로 읽으면 된다.

  • 읽을 절: 1 → 2 → 3-1 → 3-2 → 7-1 → 8-1 → 8-2 → 17의 "첫 회독 완료"
  • 건너뛸 절: 2.5, 3-3, 4~6, 7-2~7-4, 8-3, 9~16, 17의 "선택 심화 복습", 18

이 경로에서는 “누구인지 확인하는 일”과 “무엇을 허용할지 판단하는 일”을 구분하고, 인가 요청을 Subject → Action → Resource → Policy → Decision으로 분해할 수 있으면 충분하다. claim 전체 검증, Session/JWT 선택, Refresh Token Rotation, OAuth/OIDC, RBAC/ABAC/PBAC, JWT 키 운영은 두 번째 회독에서 다룬다. 건너뛴 절은 삭제 가능한 부록이 아니라 실제 구현과 운영 판단을 위한 선택 심화 자산이다.

Authentication(인증, AuthN)은 “요청자가 누구인가?”를 확인하는 과정이다. Authorization(인가, AuthZ)은 “확인된 요청자가 이 행동을 해도 되는가?”를 판단하는 과정이다.

둘은 항상 붙어 다니지만 같은 일이 아니다. 로그인 성공은 인증이고, 로그인한 사용자가 DELETE /orders/1을 호출할 수 있는지 판단하는 것은 인가다.

퀴즈

토큰이 없어서 실패한 요청과 토큰은 있지만 권한이 없어 실패한 요청은 무엇이 다른가?

힌트: 디버깅 시작점이 다르다.

정답 보기

전자는 인증 실패라 401, 후자는 인가 실패라 403이다. 401은 토큰 발급·만료·서명·쿠키 전달을 먼저 보고, 403은 role·permission·policy·resource scope를 먼저 본다.

사용자 기능, 관리자 기능, 사내 API, AWS IAM, Kubernetes RBAC, 데이터베이스 권한은 모두 같은 질문 위에 서 있다.

  1. 누가 요청했는가?
  2. 무엇을 하려는가?
  3. 어떤 대상에 대해 하려는가?
  4. 정책은 그 조합을 허용하는가?

인증과 인가를 섞으면 실패 원인을 잘못 본다. 만료된 토큰 때문에 401이 난 요청에 role을 추가해도 해결되지 않는다. 반대로 토큰은 정상인데 403이 난 요청에 재로그인을 반복해도 권한은 생기지 않는다. 이 문서의 목표는 특정 프레임워크 설정을 외우는 것이 아니라, 어떤 시스템을 만나도 identity -> token/session -> policy -> decision 흐름으로 분해할 수 있게 만드는 것이다.

2.5. 선행 기술의 한계 - 패스워드와 ACL 모델이 왜 분리된 체계로 진화했는가

섹션 제목: “2.5. 선행 기술의 한계 - 패스워드와 ACL 모델이 왜 분리된 체계로 진화했는가”

초기 웹은 사용자명과 패스워드를 매 요청에 보내거나, 서버 메모리의 세션 ID 하나로 로그인 상태와 권한을 함께 처리했다. 작은 서비스에서는 동작했지만 규모가 커지면서 두 문제가 동시에 드러났다.

인증 축의 문제: 패스워드는 사용자가 재사용하고 유출된다.

패스워드는 사람이 만들고 기억한다. 그래서 여러 서비스에 재사용되고, 피싱·인포스틸러·credential stuffing 공격에 취약하다. Verizon 2025 DBIR 기준으로 전체 breach의 22%는 탈취된 자격 증명을 초기 침투 벡터로 사용했고, SSO 인증 시도 로그의 중위값 19%가 credential stuffing 트래픽으로 관측됐으며, 인포스틸러 감염 장비의 사용자 패스워드 중 서비스별로 고유한 것은 49%뿐이었다. 이 문제는 “패스워드를 더 자주 입력하게 한다”로 풀리지 않았다.

인증 체계는 다음 방향으로 나뉘었다.

  • 서비스마다 패스워드를 직접 받지 않고 외부 Identity Provider(IdP)에 위임한다: OAuth 2.0과 OIDC
  • 탈취된 자격 증명의 유효 시간을 줄인다: 짧은 Access Token과 Refresh Token Rotation
  • 여러 API 서버가 매번 인증 서버를 조회하지 않고 검증한다: 서명된 JWT와 공개 키 검증
  • 단일 요소가 뚫려도 바로 계정이 뚫리지 않게 한다: MFA와 위험 기반 인증

인가 축의 문제: 사용자별 ACL은 운영 규모에서 폭발한다.

ACL(Access Control List)은 “이 사용자에게 이 리소스 권한을 준다”는 직접 목록이다. 사용자 1만 명과 리소스 1만 개를 직접 연결하면 최악의 경우 1억 개 관계를 관리해야 한다. 조직 개편, 팀 이동, 임시 권한, 퇴사 처리가 쌓이면 누락과 과다 권한이 동시에 생긴다.

RBAC(Role-Based Access Control)는 이 직접 연결을 Subject -> Role -> Permission의 간접 참조로 바꾼다. 사람에게 권한을 하나씩 붙이는 대신 역할에 권한을 붙이고, 사람은 역할에 배정한다. 그래서 사용자 수가 늘어도 권한 설계의 중심은 “역할과 정책”으로 이동한다. NIST의 Ferraiolo-Kuhn 모델이 1992년에 DAC/MAC의 대안으로 RBAC를 정리하고, 이후 2004년 ANSI INCITS 359로 표준화된 이유도 이 운영 문제를 줄이기 위해서다. DAC(Discretionary Access Control)는 리소스 소유자가 접근 권한을 나눠주는 모델이고, MAC(Mandatory Access Control)은 시스템의 중앙 정책과 등급이 접근을 강제하는 모델이다. L1에서는 이름만 외우기보다, RBAC가 사용자-리소스 직접 연결을 역할이라는 중간 계층으로 줄였다는 점을 잡으면 충분하다.

이 토픽이 사라지면 다음 판단이 전부 흐려진다.

  • 401과 403을 구분하지 못해 토큰 문제와 정책 문제를 섞어 본다.
  • OAuth와 OIDC를 모두 “소셜 로그인”이라고만 기억해 권한 위임과 신원 확인을 혼동한다.
  • JWT를 “암호화된 세션”으로 오해해 민감 정보를 Payload에 넣거나 로그아웃 무효화를 놓친다.
  • AWS IAM, K8s RBAC, DB privilege를 각자 다른 암기 과목처럼 다룬다.

인증/인가 문서를 읽을 때 가장 먼저 막히는 단어가 identity, principal, subject다. 셋은 비슷하지만 쓰임이 조금 다르다.

용어의미예시
Identity시스템이 알고 있는 신원 레코드사용자 계정, 서비스 계정, 조직 멤버
Principal요청 시점에 행동하는 주체로그인한 사용자, IAM Role, Kubernetes ServiceAccount
Subject토큰이나 정책 평가 안에서 식별되는 주체JWT sub, OIDC subject, policy input의 user

실무에서는 “누가 요청했는가?”를 먼저 고정해야 한다. 브라우저에서 버튼을 누른 사람, 백엔드가 대신 호출한 서비스 계정, 임시로 assume한 IAM Role은 모두 다른 principal이다. 같은 사람이 시작한 작업이어도 실제 API 호출 principal이 다르면 인가 결과도 달라진다.

인가는 항상 주체만으로 끝나지 않는다. 최소한 다음 두 축이 더 필요하다.

  • Action: 무엇을 하려는가. HTTP method, API command, IAM action, Kubernetes verb, SQL privilege가 여기에 해당한다.
  • Resource: 어디에 대해 하려는가. URL path, 주문 ID, S3 ARN, Kubernetes namespace, DB table이 여기에 해당한다.

그래서 인가 판단은 보통 (Subject, Action, Resource) 조합으로 쓴다.

subject = user:alice
action = order:delete
resource = order:123
policy decision = allow or deny

이 모델을 잡으면 프레임워크가 달라져도 같은 방식으로 볼 수 있다.

시스템SubjectActionResource
NestJS APIreq.user.subDELETE/orders/:id
AWS IAMIAM User/Role ARNs3:GetObjectarn:aws:s3:::bucket/key
KubernetesUser/Group/ServiceAccountget, create, deletepods, secrets, namespace
PostgreSQLDB roleSELECT, UPDATEtable, schema

3-3. Claim은 “검증된 주장”이어야 한다

섹션 제목: “3-3. Claim은 “검증된 주장”이어야 한다”

토큰의 claim은 “이 사용자는 admin이다”, “이 토큰은 이 API용이다”, “이 토큰은 이 시각까지 유효하다” 같은 주장이다. 중요한 점은 claim이 토큰에 들어 있다고 해서 자동으로 믿어도 되는 것이 아니라는 점이다. 서버는 서명과 필수 claim을 검증한 뒤에만 그 주장을 신뢰해야 한다.

JWT에서 자주 보는 claim은 다음과 같다.

Claim검증하지 않으면 생기는 문제
subsubject, 사용자나 서비스의 안정적인 ID이메일 변경이나 표시명 변조에 취약
ississuer, 토큰 발급자다른 서비스가 발급한 토큰이 통과
audaudience, 토큰 대상 APIA 서비스용 토큰으로 B 서비스 호출
exp만료 시각탈취 토큰이 계속 재사용
nbfnot before, 사용 시작 시각아직 유효하지 않은 토큰이 통과
iat발급 시각비정상적으로 오래된 토큰 탐지 어려움
jtiJWT ID, 토큰 고유 ID로그아웃·재사용 감지·블랙리스트 추적 어려움
scopeOAuth 권한 범위위임받지 않은 외부 리소스 접근
role 또는 permissions애플리케이션 인가 힌트과도한 권한이 토큰에 오래 남음

주의: JWT Payload는 암호화가 아니다. Base64URL로 인코딩되어 있을 뿐이라 누구나 디코딩할 수 있다. Signature는 “위조 방지”를 위한 것이지 “내용 숨김”을 위한 것이 아니다. 비밀번호, 주민등록번호, 카드번호, 내부 운영 토큰 같은 민감 정보는 Payload에 넣지 않는다.

4. 인증: “누구인지”를 확인하는 방법

섹션 제목: “4. 인증: “누구인지”를 확인하는 방법”

Session은 서버가 로그인 상태를 저장하고, 클라이언트에는 세션 ID만 전달하는 방식이다. 브라우저는 보통 쿠키로 세션 ID를 보내고, 서버는 Redis나 DB에서 세션을 조회한다.

1. 사용자가 로그인한다.
2. 서버가 session_id = abc123을 만들고 Redis에 저장한다.
3. 브라우저는 Cookie: sid=abc123을 이후 요청마다 보낸다.
4. 서버는 sid로 세션 저장소를 조회해 사용자를 식별한다.

장점은 서버가 상태를 쥐고 있다는 점이다. 계정 정지, 로그아웃, 디바이스 강제 종료를 즉시 반영하기 쉽다. 단점은 모든 API 서버가 같은 세션 저장소를 조회해야 하므로 공유 저장소와 장애 처리가 필요하다.

세션 쿠키를 쓰면 CSRF(Cross-Site Request Forgery)도 함께 봐야 한다. 브라우저가 쿠키를 자동으로 붙이기 때문에, 공격 사이트가 사용자의 브라우저를 통해 요청을 보내는 문제가 생길 수 있다. SameSite, CSRF token, Origin/Referer 검증이 세션 기반 인증에서 중요한 이유다.

JWT(JSON Web Token)는 Header, Payload, Signature로 구성된 서명된 토큰이다. API 서버는 Signature와 claim을 검증해 토큰이 진짜인지 판단한다. 매 요청마다 인증 서버나 DB를 조회하지 않아도 된다는 점이 핵심이다.

JWT 발급과 검증 흐름
sequenceDiagram
participant C as Client
participant A as Auth Server
participant API as API Server

C->>A: 로그인 요청
A-->>C: Header + Payload + Signature로 만든 JWT 발급
C->>API: Authorization: Bearer JWT
API->>API: 서명, iss, aud, exp, nbf 검증
API-->>C: 검증된 subject 기반으로 응답

JWT가 유용한 상황은 “여러 서비스가 같은 토큰을 검증해야 하고, 매번 중앙 세션 저장소를 조회하기 어렵다”는 조건이다. 반대로 로그아웃 즉시 반영, 계정 정지, 디바이스별 세션 제어가 핵심이면 JWT만으로는 부족하다. 결국 jti 블랙리스트나 refresh token 저장소가 필요해지고, 이 순간 일부 상태를 다시 서버가 갖게 된다.

JWT 검증의 최소 조건

1. 허용 알고리즘인지 확인한다. 예: HS256만, 또는 RS256만.
2. Signature를 검증한다.
3. iss가 내가 신뢰하는 발급자인지 확인한다.
4. aud가 이 API인지 확인한다.
5. exp, nbf를 현재 시각과 비교한다.
6. 필요한 경우 jti가 폐기 목록에 없는지 확인한다.
7. 그 뒤에야 sub, role, scope를 인가 입력으로 사용한다.

alg: none 공격은 1번을 빼먹었을 때 생긴다. JWT 헤더는 클라이언트가 보낼 수 있는 값이므로, 서버가 헤더의 alg를 그대로 믿으면 서명 없는 토큰을 통과시킬 수 있다. 라이브러리 기본값에 기대지 말고 허용 알고리즘을 명시한다.

// 핵심만 남긴 안전한 JWT 검증 설정 예시
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: "15m",
algorithm: "HS256",
issuer: "my-app",
audience: "my-api",
},
verifyOptions: {
algorithms: ["HS256"],
issuer: "my-app",
audience: "my-api",
clockTolerance: 60,
},
});

clockTolerance: 60은 분산 서버의 시계 오차를 60초까지 허용한다는 뜻이다. 실무에서는 expnbf 검증에 1~5분 정도의 leeway를 두는 경우가 많다. 이 값이 너무 크면 만료 토큰이 오래 살아남고, 너무 작으면 서버 시각 차이 때문에 정상 사용자가 401을 볼 수 있다.

Session vs JWT 선택 기준

Session

서버 저장소에 로그인 상태를 둔다. 즉시 로그아웃, 계정 정지, 디바이스 제어가 쉽다.

내부 어드민, 단일 서비스, 보안 이벤트 대응이 중요한 서비스

JWT

서명된 토큰을 각 API 서버가 검증한다. 중앙 조회를 줄이고 서비스 간 전달이 쉽다.

여러 API 서버, 외부 검증자, 짧은 Access Token 중심 구조

잘못된 선택의 대표 사례는 “마이크로서비스니까 무조건 JWT”라고 고르는 것이다. 내부 어드민처럼 계정 정지와 로그아웃 즉시 반영이 중요한 서비스에 JWT를 쓰면, 결국 모든 요청에서 Redis 블랙리스트나 세션 상태를 다시 확인하게 된다. 그러면 stateless 이점은 사라지고 토큰 크기와 키 관리 비용만 남는다.

반대로 여러 독립 API가 같은 사용자 인증을 검증해야 하고 인증 서버 호출을 줄여야 한다면 JWT가 자연스럽다. 이때도 Access Token 수명은 짧게 잡고, 장기 세션은 Refresh Token 쪽에서 관리한다.

5. 토큰 수명과 Refresh Token Rotation

섹션 제목: “5. 토큰 수명과 Refresh Token Rotation”

Access Token은 API 접근에 직접 쓰는 짧은 토큰이다. Refresh Token은 새 Access Token을 받기 위한 긴 토큰이다. 둘을 나누는 이유는 “자주 쓰는 토큰은 짧게, 오래 가는 권한은 더 강하게 추적”하기 위해서다.

권장 감각은 다음과 같다.

토큰일반적인 수명이유
Access Token외부 API 5~15분, 내부 저위험 서비스 최대 60분탈취돼도 피해 시간을 줄인다.
Refresh Token7~14일 또는 정책상 더 짧게사용자 경험을 유지하되 서버가 저장·회전·폐기한다.

Refresh Token Rotation은 refresh token을 사용할 때마다 새 refresh token으로 교환하고, 이전 refresh token은 즉시 폐기하는 방식이다.

Refresh Token Rotation 흐름
sequenceDiagram
participant C as Client
participant A as Auth Server
participant X as Attacker

A-->>C: Access Token + Refresh Token-A 발급
C->>A: Token-A로 재발급 요청
A-->>C: Token-A 폐기, Access Token + Refresh Token-B 발급
X->>A: 탈취한 Token-A 재사용 시도
A-->>X: 재사용 감지, 세션 토큰 무효화

Rotation이 없으면 refresh token 하나가 탈취됐을 때 공격자가 만료 전까지 계속 새 access token을 받을 수 있다. Rotation이 있으면 이미 사용된 refresh token이 다시 들어오는 순간 “정상 사용자와 공격자 중 하나가 토큰을 공유하고 있다”는 신호로 볼 수 있다. 여기서 refresh token family는 한 로그인 세션에서 순차적으로 발급된 refresh token들의 체인이다. 이 경우 해당 family를 모두 폐기하고 재로그인을 요구하는 것이 안전하다. Refresh Token 저장소에는 원문 토큰을 그대로 두지 않는 편이 안전하다. 데이터베이스나 Redis snapshot이 유출돼도 바로 재사용하지 못하도록, 충분히 긴 랜덤 토큰을 발급하고 서버에는 해시값과 family id, 만료 시각, 폐기 여부를 저장한다.

재사용 감지의 핵심은 원자성이다.

나쁜 흐름:
1. Redis에서 refresh token을 읽는다.
2. 요청 토큰과 비교한다.
3. 새 token을 저장한다.
동시 요청 2개가 1번을 거의 동시에 통과하면 둘 다 성공할 수 있다.
더 안전한 흐름:
1. GETDEL, Lua script, transaction, SET NX 같은 원자 연산으로 기존 토큰을 회수한다.
2. 회수된 값이 요청 토큰과 일치할 때만 새 토큰을 저장한다.
3. 불일치하거나 이미 없으면 reuse로 보고 세션을 폐기한다.

이 설계는 보안상 fail-closed여야 한다. Redis나 토큰 저장소 조회가 실패했을 때 “사용자 경험을 위해 통과”시키면 저장소 장애가 인증 우회가 된다. 오류가 나면 401로 막고 재시도를 유도하는 쪽이 맞다.

OAuth 2.0은 인증 프로토콜이 아니라 권한 위임(authorization delegation) 프로토콜이다. “이 앱이 내 Google Drive 파일을 읽어도 된다”처럼 사용자가 리소스 접근 권한을 제3자 앱에 위임하는 표준이다.

OIDC(OpenID Connect)는 OAuth 2.0 위에 인증 레이어를 얹은 표준이다. Authorization Code Flow를 사용하되, Access Token 외에 ID Token을 받아 “이 사용자가 누구인지”를 확인한다.

구분OAuth 2.0OIDC
주된 질문이 앱이 어떤 리소스에 접근해도 되는가?이 사용자는 누구인가?
핵심 토큰Access TokenID Token + Access Token
예시Google Calendar 읽기 권한 위임Google로 로그인
필수 스코프서비스마다 다름openid
OIDC Authorization Code Flow
sequenceDiagram
participant U as User
participant B as Browser
participant App as App Server
participant IdP as Identity Provider

U->>B: "Google로 로그인" 클릭
B->>IdP: client_id, redirect_uri, scope=openid email, state, code_challenge
IdP-->>B: callback?code=...&state=...
B->>App: Authorization Code 전달
App->>IdP: code + code_verifier를 토큰으로 교환
IdP-->>App: ID Token + Access Token
App->>App: ID Token 검증 후 자체 세션/JWT 발급

Authorization Code를 한 번 더 교환하는 이유는 브라우저 주소창과 로그에 Access Token을 직접 노출하지 않기 위해서다. code는 짧게 살고 일회성이다. 서버가 code를 token endpoint로 보내 실제 토큰을 받는다.

PKCE(Proof Key for Code Exchange)는 SPA나 모바일 앱처럼 client_secret을 안전하게 보관하기 어려운 환경에서 code 탈취를 막기 위한 보강이다. 클라이언트가 code_verifier를 만들고 그 해시인 code_challenge를 인증 요청에 보낸다. 나중에 토큰 교환 시 원본 code_verifier를 제출해야 하므로, 중간에서 code만 훔친 공격자는 토큰을 받을 수 없다.

OAuth/OIDC에서 state는 CSRF 방어에 중요하다. 로그인 요청을 시작할 때 만든 난수를 서버 세션이나 안전한 저장소에 보관하고, callback의 state와 일치하는지 확인한다. Invalid state는 단순 귀찮은 에러가 아니라 “내가 시작하지 않은 인증 응답일 수 있다”는 신호다. OIDC의 ID Token을 검증할 때는 JWT 일반 검증(iss, aud, exp, 서명)과 함께 nonce도 본다. nonce는 로그인 요청과 ID Token 응답을 묶어 replay된 인증 응답을 걸러내기 위한 값이다.

7. 인가: “무엇을 해도 되는지”를 판단하는 방법

섹션 제목: “7. 인가: “무엇을 해도 되는지”를 판단하는 방법”

인가 시스템은 구현 방식이 달라도 대체로 다음 순서로 생각한다.

input:
subject = user:alice
action = order:refund
resource = order:123
context = { ownerId: "alice", amount: 30000, ip: "203.0.113.10" }
policy:
allow support_agent to refund order when amount <= 50000
deny refund when order.status = "settled_90_days_ago"
decision:
explicit deny가 있으면 deny
allow 규칙이 있으면 allow
아무 규칙도 맞지 않으면 deny

핵심 원칙은 deny-by-default다. 명시적으로 허용되지 않은 조합은 거부해야 한다. allow-by-default는 정책 누락이 곧 권한 상승(privilege escalation)이 되는 구조다. 또 explicit deny가 있으면 allow보다 우선해야 한다. 조직 정책, 법적 제한, 위험 상태를 allow 규칙으로 덮어쓰면 안 되기 때문이다.

RBAC(Role-Based Access Control)는 역할에 권한을 묶고, 사용자에게 역할을 부여하는 방식이다.

user:alice -> role:editor
role:editor -> permission:article:create
role:editor -> permission:article:update
role:viewer -> permission:article:read

NestJS로 보면 컨트롤러의 @Roles("admin") 같은 선언은 “이 action/resource 조합은 admin role이 있어야 한다”는 정책을 코드에 표현한 것이다.

// 핵심 흐름만 남긴 RBAC 예시
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete(":id")
@Roles("admin")
remove(@Param("id") id: string, @Request() req) {
// JwtAuthGuard: req.user를 만든다. AuthN.
// RolesGuard: req.user.roles에 admin이 있는지 확인한다. AuthZ.
return this.ordersService.remove(id);
}

요청 결과는 다음처럼 갈린다.

GET /orders
Authorization: Bearer <valid-token>
-> 200 OK
GET /orders
Authorization: 없음
-> 401 Unauthorized
DELETE /orders/1
Authorization: Bearer <viewer-token>
-> 403 Forbidden

RBAC의 장점은 단순하고 감사하기 쉽다는 점이다. 단점은 조건이 늘어나면 역할 폭발(role explosion)이 생긴다는 점이다. Admin-Asia-BusinessHours-OwnTeamOnly처럼 지역, 시간, 소유 관계, 데이터 민감도까지 역할 이름에 밀어 넣기 시작하면 RBAC가 더 이상 단순하지 않다.

ABAC(Attribute-Based Access Control)는 subject, resource, environment의 속성을 정책에 넣는다.

allow if
subject.department == resource.ownerDepartment
and action == "read"
and request.ip in corporate_network

PBAC(Policy-Based Access Control)는 정책 언어와 정책 엔진을 중심에 둔다. OPA/Rego, Cedar 같은 도구가 이 범주에 들어간다. 정책을 애플리케이션 코드에서 분리해 여러 서비스가 같은 방식으로 평가하게 만들 수 있다.

RBAC, ABAC, PBAC 선택 기준

RBAC

역할과 권한 매핑으로 대부분의 CRUD 권한을 단순하게 표현한다.

역할 수가 적고 정책을 사람이 감사해야 할 때

ABAC

부서, 소유자, 시간, 위치, 리소스 민감도 같은 속성으로 판단한다.

역할 이름에 조건이 계속 붙어 role explosion이 시작될 때

PBAC

정책 엔진과 정책 언어로 allow/deny를 중앙화한다.

여러 서비스와 조직 정책을 일관되게 평가해야 할 때

처음부터 PBAC를 도입할 필요는 없다. L1에서 중요한 판단은 “role만 보면 충분한가, 아니면 요청 맥락과 리소스 속성이 정책의 일부인가?”다. 맥락이 정책의 핵심이면 RBAC만으로는 부족하다.

Least privilege는 주체에게 필요한 최소 권한만 주는 원칙이다. “나중에 막는 것”이 아니라 “처음에는 닫고 필요한 것만 여는 것”이다.

권한 상승은 보통 극적인 해킹보다 작은 누락에서 시작한다.

  • 사용자 본인의 주문만 볼 수 있어야 하는데 orderId만 검증하고 ownerId를 확인하지 않는다.
  • viewer에게 읽기 권한만 줬지만 토큰의 permissions claim이 오래 살아 과거 권한이 남는다.
  • 내부 API라며 admin=true 헤더를 신뢰한다.
  • S3 ListBucket만 필요했는데 s3:*를 붙인다.

정책 리뷰에서는 항상 “이 allow가 너무 넓지 않은가?”와 “명시적 deny 또는 기본 deny가 있는가?”를 확인한다.

401은 인증 실패다. 이름은 Unauthorized지만 실제 의미는 “인증되지 않았거나 인증을 다시 해야 한다”에 가깝다. 서버는 보통 WWW-Authenticate 헤더나 에러 메시지로 어떤 인증이 필요한지 알려준다.

401에서 먼저 볼 것:

  1. Authorization 헤더나 세션 쿠키가 실제로 전달됐는가?
  2. Bearer 접두사와 공백이 맞는가?
  3. 토큰 exp가 지났는가?
  4. 서명 키가 배포 중 바뀌었는가?
  5. iss, aud, nbf, clock skew 검증에서 막혔는가?
  6. 로그아웃이나 보안 이벤트로 jti가 폐기됐는가?

403은 인증은 됐지만 인가가 거부됐다는 뜻이다. 재로그인으로 해결될 수도 있지만, 기본적으로는 정책·역할·리소스 scope 문제다.

403에서 먼저 볼 것:

  1. 현재 principal이 기대한 사용자나 서비스 계정인가?
  2. 필요한 action이 정확히 무엇인가?
  3. resource scope가 맞는가? 예: 버킷 vs 객체, namespace, tenant ID
  4. 토큰 claim의 role/permission이 현재 DB 권한과 동기화돼 있는가?
  5. 명시적 deny가 allow를 덮고 있는가?
  6. 리소스 소유자 조건을 빼먹지 않았는가?
증상더 가까운 문제먼저 볼 곳
모든 사용자가 동시에 401인증 키/issuer/audience/배포 문제JWT secret, JWKS, IdP 설정
특정 사용자만 401토큰 만료, 계정 폐기, refresh 재사용exp, jti, 세션 상태
로그인은 되는데 관리자 API만 403인가 정책 문제role, permission, policy
본인 주문 조회가 403리소스 scope 또는 owner 조건ownerId, tenant, resource policy
OAuth callback에서 Invalid stateCSRF 방어 또는 redirect mismatch저장된 state, callback URL
토큰이 큰 사용자만 실패헤더 크기 제한claim 수, 쿠키 합산 크기, proxy limit

9. 선택 심화: JWT와 키 관리의 경계

섹션 제목: “9. 선택 심화: JWT와 키 관리의 경계”

첫 회독에서는 HS256은 shared secret, RS256은 private/public key라는 차이와 “키를 어디까지 배포해야 하는가”만 잡아도 충분하다. RSA 세부 수학이나 JWKS 운영은 여러 서비스가 같은 토큰을 검증하는 구조를 다룰 때 다시 돌아오면 된다. 공개키 암호화(RSA) 기초는 이 선택 심화 절을 깊게 읽을 때만 필요하다. 인증과 인가의 기본 흐름을 읽기 위한 hard prerequisite는 아니다.

HS256은 하나의 비밀 키로 서명과 검증을 모두 한다. 단일 서버나 검증자가 매우 적은 환경에서는 단순하고 빠르다. 단점은 검증자 모두가 같은 비밀 키를 알아야 한다는 점이다. 검증 서버 중 하나가 뚫리면 서명 키도 뚫릴 수 있다. HS256을 쓴다면 password, my-secret 같은 사람이 만든 문자열은 secret이 아니다. 최소 256-bit 수준의 랜덤 secret을 쓰고, 환경변수 파일에 흩뿌리기보다 Secret Manager나 KMS 같은 저장소로 배포 범위와 회전 절차를 관리해야 한다.

RS256은 개인 키로 서명하고 공개 키로 검증한다. 인증 서버만 개인 키를 갖고, API 서버들은 공개 키만 갖는다. 공개 키는 유출돼도 서명에는 쓸 수 없으므로 여러 서비스가 토큰을 검증하는 환경에 더 적합하다.

HS256 vs RS256 선택 기준

HS256

하나의 shared secret으로 서명과 검증을 모두 수행한다.

단일 서비스, 키 배포 대상이 거의 없고 회전 범위가 작을 때

RS256

개인 키로 서명하고 공개 키로 검증한다. 검증자는 비밀을 몰라도 된다.

마이크로서비스, 외부 검증자, JWKS 기반 키 회전이 필요할 때

성능만으로 고르지 않는다. 10K req/s 수준에서도 RSA 검증 비용보다 네트워크 I/O, DB 조회, 애플리케이션 로직이 더 큰 경우가 많다. 선택 기준은 키 분배와 회전 모델이다. 흔한 구성을 명시해서 비교해보자. 2048-bit RSA key와 compact JWS의 padding 없는 base64url 인코딩을 가정하면 RS256 signature는 raw 256 bytes가 342 ASCII bytes가 되고, HS256은 SHA-256 HMAC raw 32 bytes가 43 ASCII bytes가 된다. 따라서 같은 header와 payload를 쓴 토큰에서 signature segment 차이는 약 299 bytes이며, 이는 AWS ALB의 전체 요청 헤더 64 KiB(65,536 bytes) 한도299 / 65,536 ≈ 0.46%다. 이 값은 특정 key size와 인코딩을 둔 common-case 계산이지 모든 JWT의 고정 증가량이 아니다. 따라서 보통은 “토큰 크기”보다 “비밀 키를 몇 곳에 뿌리는가”가 더 큰 결정 요인이다. 다만 실제 실패 여부는 토큰 하나가 아니라 cookie와 프록시가 추가한 헤더까지 합친 전체 크기로 판단한다.

모놀리스에서 4개 서비스로 분리되는 상황을 생각해보자. HS256을 유지하면 API, Order, Payment, Notification 서비스와 CI, 로컬 개발 환경 모두 같은 JWT_SECRET을 가져야 한다. 키를 교체하려면 모두 동시에 바뀌어야 하고, 하나라도 오래된 키를 쓰면 401이 난다. RS256 + JWKS를 쓰면 인증 서버만 개인 키를 갖고, 나머지는 /.well-known/jwks.json의 공개 키를 캐시해 검증한다.

JWKS(JSON Web Key Set)는 공개 키 목록을 JSON으로 제공하는 방식이다. JWT header의 kid는 어떤 키로 서명했는지 알려주는 key id다.

무중단 키 전환의 기본 흐름:
1. 새 키 쌍을 만든다. kid = key-v2.
2. JWKS에 기존 key-v1과 새 key-v2를 함께 노출한다.
3. 새 토큰은 key-v2로 서명한다.
4. 기존 key-v1 토큰은 access/refresh 정책상 유예 기간 동안 계속 검증한다.
5. 유예 기간이 끝나면 JWKS에서 key-v1을 제거하고 개인 키를 폐기한다.

HS256의 단일 비밀 키를 즉시 바꾸면 기존 access token은 모두 깨진다. 이 상황이 보안 사고 대응이라면 의도한 전체 무효화일 수 있다. 하지만 일반 키 회전이라면 사용자 전체 재로그인을 만들지 않도록 이전 키와 새 키를 함께 검증할 수 있는 구조가 필요하다. 첫 회독에서는 kid가 “어떤 공개 키로 검증할지 고르는 힌트”이고, JWKS가 “공개 키 목록을 배포하는 표준 위치”라는 정도만 잡아도 된다.

JWT는 보통 HTTP header나 cookie로 전송된다. 그래서 claim을 많이 넣으면 인프라 제한에 걸린다.

인프라헤더 크기 감각
Nginx 기본 large_client_header_buffers버퍼 4개 x 8KB
AWS ALB단일 헤더 16KB, 전체 요청 헤더 64KB
Cloudflare단일 헤더 값 16KB 수준

역할, 권한, 조직, 기능 플래그를 모두 claim에 넣으면 토큰이 1~3KB 이상으로 커질 수 있다. 여기에 쿠키 여러 개가 합쳐지면 특정 사용자만 431 또는 400 계열 오류를 볼 수 있다. 상세 권한은 DB/Redis에서 조회하고 토큰에는 안정적인 식별자와 최소 claim만 넣는 편이 안전하다.

요구사항:

  • 일반 사용자는 본인 주문을 읽을 수 있다.
  • 일반 사용자는 주문을 삭제할 수 없다.
  • 관리자는 주문을 삭제할 수 있다.
  • 보안 사고로 정지된 계정은 어떤 action도 할 수 없다.

정책을 말로 쓰면 다음과 같다.

deny if subject.status == "suspended"
allow if action == "order:read" and resource.ownerId == subject.id
allow if action == "order:delete" and subject.roles includes "admin"
deny otherwise

세 요청을 평가해보자.

SubjectActionResourceDecision이유
alice(user)order:readorder.ownerId=aliceallow본인 리소스 읽기
alice(user)order:deleteorder.ownerId=alicedeny본인 주문이어도 삭제 권한 없음
bob(admin, suspended)order:deleteorder.ownerId=alicedenyexplicit deny가 admin allow보다 우선

이 예제의 핵심은 “역할이 높으면 항상 허용”이 아니라는 점이다. 보안 상태, 리소스 소유자, action의 위험도가 정책의 일부다.

S3 접근 거부도 같은 모델로 본다.

Subject: arn:aws:iam::123456789012:role/app-server
Action: s3:GetObject
Resource: arn:aws:s3:::my-bucket/orders/2026-07/report.csv
Decision: explicitDeny, implicitDeny, or allow

실전에서 긴 CLI를 외울 필요는 없다. 먼저 3축을 채운다.

  • Subject: 현재 요청이 어느 IAM User/Role/assumed-role로 나갔는가?
  • Action: s3:GetObject인가, s3:ListBucket인가? 둘은 다른 권한이다.
  • Resource: 버킷 ARN인가, 객체 ARN인가? ListBucket은 버킷, GetObject는 객체 scope가 필요하다.

선택 확인 명령은 다음 정도면 학습 목적에 충분하다.

Terminal window
aws sts get-caller-identity
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::123456789012:role/app-server" \
--action-names "s3:GetObject" \
--resource-arns "arn:aws:s3:::my-bucket/orders/2026-07/report.csv"

implicitDeny는 “허용 정책을 못 찾았다”는 뜻이고, explicitDeny는 “거부 정책이 직접 막았다”는 뜻이다. 둘 다 거부지만 해결 방향이 다르다. implicit deny는 필요한 allow를 추가할지 검토하고, explicit deny는 조직 정책·SCP·bucket policy·permission boundary를 먼저 확인한다.

10-3. Kubernetes와 DB 권한도 같은 질문이다

섹션 제목: “10-3. Kubernetes와 DB 권한도 같은 질문이다”

Kubernetes RBAC는 다음처럼 묻는다.

Terminal window
kubectl auth can-i create pods --namespace=dev
kubectl auth can-i delete secrets --namespace=prod --as=jane

PostgreSQL은 다음처럼 묻는다.

SELECT has_table_privilege('alice', 'orders', 'SELECT');

명령은 다르지만 질문은 같다. “이 subject가 이 action을 이 resource에 대해 할 수 있는가?”라는 모델이 먼저이고, 도구 명령은 그 모델을 확인하는 방법일 뿐이다.

Access Token이 로그, 브라우저 localStorage, URL query string, 에러 리포트에 남으면 공격자가 그대로 replay할 수 있다. 서명 검증은 “발급자가 맞다”를 확인할 뿐 “토큰을 들고 온 사람이 원래 사용자다”를 보장하지 않는다.

실패 신호:

  • 로그에 Authorization: Bearer ...가 남는다.
  • OAuth callback URL이나 프론트 라우터에 access token이 query string으로 보인다.
  • 동일 jti나 동일 refresh token이 다른 IP/디바이스에서 재사용된다.
  • 로그아웃 후 access token이 만료 전까지 계속 API를 통과한다.

대응 기준:

  • 토큰은 URL에 넣지 않는다.
  • 로그와 APM에서 Authorization header, cookie, token field를 마스킹한다.
  • Access Token은 짧게 유지한다.
  • Refresh Token은 서버에 저장하고 rotation/reuse detection을 적용한다.
  • 고위험 서비스는 device binding, mTLS, DPoP 같은 replay 완화도 검토한다.

세션 쿠키 기반 인증은 CSRF에 취약할 수 있다. 브라우저가 쿠키를 자동 전송하기 때문이다. 반면 JWT를 JS 메모리나 localStorage에 두면 CSRF는 줄어들 수 있지만 XSS로 토큰을 직접 훔칠 위험이 커진다.

저장 위치강한 점약한 점
HttpOnly Secure SameSite cookieJS가 토큰을 읽기 어렵다.CSRF 방어와 SameSite 설계가 필요하다.
JS 메모리새로고침 시 사라져 노출 시간이 짧다.탭 유지와 silent refresh 설계가 필요하다.
localStorage구현이 쉽고 오래 유지된다.XSS가 있으면 토큰 탈취가 쉽다.

정답은 저장 위치 하나가 아니라 위협 모델이다. 중요한 것은 토큰 누출, replay, CSRF, XSS 중 무엇을 더 크게 보는지 명확히 하고 보완책을 같이 두는 것이다.

11-3. 401/403을 숨기려고 404를 쓰는 경우

섹션 제목: “11-3. 401/403을 숨기려고 404를 쓰는 경우”

리소스 존재 여부 자체가 민감하면 403 대신 404를 반환할 수 있다. 예를 들어 /users/{id}/medical-records에서 “권한은 없지만 리소스는 존재함”을 알려주는 것 자체가 정보 노출일 수 있다.

하지만 내부 로그와 정책 decision은 403/deny로 남아야 한다. 클라이언트 응답은 404로 감추더라도 서버 관측성에서 인증 실패와 인가 실패를 구분하지 못하면 운영 대응이 어려워진다.

JWT에 role: "admin"을 넣으면 API 서버는 DB 조회 없이 빠르게 판단할 수 있다. 하지만 사용자가 admin에서 내려간 뒤에도 기존 토큰이 만료되기 전까지는 claim이 남는다. 이 문제를 줄이는 선택지는 세 가지다.

  1. Access Token 수명을 짧게 둔다.
  2. 권한 변경 시 해당 사용자의 jti나 token family를 폐기한다.
  3. 고위험 action은 DB/Redis에서 최신 권한을 한 번 더 확인한다.

모든 권한을 토큰에 넣는 것은 단순해 보이지만, 변경이 잦은 권한일수록 stale claim 위험이 커진다.

이 절은 긴 런북이 아니라 증상에서 개념으로 돌아가는 빠른 지도다.

증상의미확인할 개념
Unknown authentication strategy 'jwt'프레임워크가 JWT 검증기를 등록하지 못함인증 미들웨어/Guard 구성
토큰이 있는데 401토큰 전달 또는 검증 실패Bearer 형식, exp, secret/JWKS, iss/aud
인증은 됐는데 403정책 평가에서 denyrole, permission, owner, resource scope
OAuth Invalid statecallback이 시작 요청과 매칭되지 않음CSRF 방어, redirect URI
로그아웃 후 API 성공access token이 아직 유효짧은 수명, jti 폐기, blacklist
refresh 동시 요청이 둘 다 성공rotation 원자성 실패GETDEL, transaction, token family 폐기
특정 사용자만 헤더 오류claim/cookie 크기 초과claim 최소화, 권한 조회 분리
선택 부록: 명령으로 확인하는 최소 예시

JWT payload는 디버깅 목적으로만 디코딩한다. 디코딩은 검증이 아니다.

Terminal window
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzQzNTAwMDAwfQ.xxx"
echo "$TOKEN" | cut -d "." -f2 | base64 -d 2>/dev/null

Redis 블랙리스트는 “토큰을 서버가 기억하고 있는가?”를 확인하는 용도다.

Terminal window
redis-cli GET "blacklist:<jti>"

NestJS의 Unknown authentication strategy 'jwt'는 보통 JwtStrategy provider 등록이나 PassportModule import 누락이다. 전체 설정 파일을 외우기보다 “요청 전에 인증 전략이 DI 컨테이너에 등록됐는가?”로 보면 된다.

  • 즉시 로그아웃, 계정 정지, 내부 어드민이 중요하면 Session + 서버 저장소가 단순하다.
  • 여러 API 서버가 독립적으로 검증해야 하면 JWT가 적합하다.
  • 제3자 로그인이나 권한 위임이 필요하면 OAuth/OIDC를 사용한다.
  • 브라우저 기반 공개 클라이언트에서는 Authorization Code Flow + PKCE를 기본으로 본다.
  • 토큰을 오래 살려야 한다면 refresh token 저장·회전·재사용 감지를 함께 설계한다.
  • 역할 몇 개로 설명되는 CRUD 권한이면 RBAC로 시작한다.
  • 시간, 위치, 소유자, 부서, 데이터 민감도 조건이 핵심이면 ABAC를 검토한다.
  • 여러 서비스가 같은 정책을 공유하고 감사해야 하면 PBAC/정책 엔진을 검토한다.
  • 기본은 deny-by-default다. allow 규칙은 좁게 쓰고, explicit deny는 allow보다 우선시한다.
  • 정책 변경은 “누가 무엇을 어디에 대해 할 수 있게 됐는가?”로 리뷰한다.
  • sub, iss, aud, exp, iat, 필요 시 jti는 기본으로 본다.
  • 민감 정보는 넣지 않는다.
  • 변경이 잦은 권한은 토큰에 크게 넣지 않는다.
  • role/permission claim을 넣는다면 Access Token 수명을 짧게 잡는다.
  • claim이 많아질수록 헤더 크기와 stale 권한 위험이 커진다.
  • API 장애 티켓에서 401/403을 구분해 담당 영역을 좁힌다.
  • 관리자 기능 구현 시 JwtAuthGuardRolesGuard가 각각 AuthN/AuthZ임을 분리해 본다.
  • AWS IAM AccessDenied를 principal/action/resource/decision으로 읽는다.
  • OAuth 로그인 구현 시 OAuth만으로 사용자 신원을 확인했다고 착각하지 않고 OIDC ID Token을 검증한다.
  • refresh token 저장소 장애가 인증 우회로 이어지지 않도록 fail-closed로 설계한다.

SAML, MFA, OIDC Discovery, JWKS, PKCE, DPoP, mTLS, device binding, SCIM, OPA/Rego, Cedar, AWS IAM SCP, permission boundary, tenant isolation, CSRF, XSS

첫 회독 완료

  • Authentication과 Authorization의 차이를 한 문장으로 설명할 수 있다.
  • Identity, principal, subject의 차이를 예시로 설명할 수 있다.
  • 401과 403의 차이를 보고 디버깅 시작점을 고를 수 있다.
  • 인가 문제를 Subject, Action, Resource, Policy, Decision으로 분해할 수 있다.
  • 명시적으로 허용되지 않은 요청은 거부한다는 deny-by-default를 설명할 수 있다.

아래 항목은 첫 회독의 완료 조건이 아니다. 건너뛴 선택 심화 절을 다시 읽은 뒤 복습한다.

선택 심화 복습

  • JWT Payload가 암호화가 아니라는 점과 넣으면 안 되는 정보를 말할 수 있다.
  • sub, iss, aud, exp, jti claim이 왜 필요한지 설명할 수 있다.
  • Session과 JWT를 즉시 로그아웃, 수평 확장, 서버 상태 기준으로 비교할 수 있다.
  • OAuth 2.0과 OIDC의 경계를 Access Token과 ID Token으로 설명할 수 있다.
  • Refresh Token Rotation과 reuse detection의 보안 의미를 설명할 수 있다.
  • RBAC, ABAC, PBAC를 정책 조건의 복잡도 기준으로 구분할 수 있다.
  • least privilege와 deny-by-default가 권한 상승을 어떻게 줄이는지 설명할 수 있다.
질문먼저 볼 것
”누구인가?”Authentication, session, JWT, OIDC ID Token
”무엇을 해도 되는가?”Authorization, RBAC/ABAC/PBAC, policy decision
401이 뜨는가?토큰·쿠키 전달, 서명, 만료, issuer/audience
403이 뜨는가?role, permission, owner, action, resource scope
JWT를 쓸까?분산 검증 필요성과 즉시 무효화 요구를 비교
OAuth만 쓰면 로그인인가?아니다. 신원 확인은 OIDC ID Token 검증이 필요
Refresh Token은 왜 회전하나?탈취된 긴 수명 토큰의 재사용을 감지하기 위해
정책 기본값은?deny-by-default, least privilege

5줄로 줄이면 다음과 같다.

  1. 인증은 subject를 확정하고, 인가는 subject/action/resource 조합을 평가한다.
  2. 401은 인증 실패, 403은 인가 실패다.
  3. JWT는 서명된 claim 묶음이지 암호화된 세션이 아니다.
  4. OAuth 2.0은 권한 위임이고, OIDC는 그 위에 신원 확인을 얹는다.
  5. 권한 정책은 least privilege와 deny-by-default로 시작해야 한다.