Published on

AWS S3 AccessDenied 403 - 정책·KMS·VPCE 점검

Authors

서버/배치/애플리케이션에서 S3 호출이 갑자기 403 AccessDenied로 떨어지면 대부분은 “IAM 권한이 없나?”로 시작하지만, 실제 현장에서는 버킷 정책의 명시적 Deny, SSE-KMS의 키 정책/그랜트, VPC 엔드포인트(S3 Gateway/Interface)의 정책·라우팅 같은 네트워크/암호화 계층에서 막히는 경우가 더 까다롭습니다. 더 골치 아픈 점은, S3는 동일한 403이라도 원인이 다르면 로그/CloudTrail/에러 메시지의 단서가 달라서 진단 순서가 중요합니다.

이 글은 “지금 당장 403을 풀어야 하는” 상황에서, 원인을 빠르게 좁히는 체크리스트를 버킷 정책 → SSE-KMS → VPC 엔드포인트 중심으로 정리합니다. (EKS/사설망 환경에서 특히 자주 터집니다. 네트워크 계층 진단 패턴은 EKS Pod는 뜨는데 트래픽 0 - NetPol·SG·CNI 10분 진단도 함께 참고하면 좋습니다.)

1) 403 AccessDenied를 “어떤 403인지” 먼저 분류하기

1-1. 클라이언트 에러 메시지에서 단서 뽑기

S3는 SDK/CLI에서 보통 이런 식으로 보입니다.

  • An error occurred (AccessDenied) when calling the GetObject operation: Access Denied
  • AccessDenied: Access Denied (boto3)
  • Forbidden (프록시/게이트웨이 경유)

여기서 중요한 건 **어떤 API가 막혔는지(GetObject/PutObject/ListBucket/HeadObject 등)**입니다. 예를 들어:

  • ListBucket 403 → s3:ListBucket(버킷 ARN) 권한/조건 문제
  • GetObject 403 → s3:GetObject(오브젝트 ARN) + (SSE-KMS면) kms:Decrypt 문제 가능
  • PutObject 403 → s3:PutObject + (SSE-KMS면) kms:Encrypt/kms:GenerateDataKey 문제 가능

1-2. CloudTrail에서 “거부 주체” 확인

가장 빠른 방법은 CloudTrail Event history에서 해당 시간대 S3 이벤트를 보고, errorCode=AccessDenied와 함께:

  • userIdentity (누가 호출했는지: IAM Role/AssumedRole/IRSA)
  • eventName (GetObject/PutObject 등)
  • resources (버킷/키)
  • additionalEventData / requestParameters

를 확인하는 것입니다.

CLI로도 좁힐 수 있습니다(계정에 따라 조회 범위 제한 가능).

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
  --max-results 20

> 팁: EKS라면 IRSA 관련 AssumeRole 단계부터 막히는 케이스도 있어, S3만 보지 말고 STS 이벤트도 같이 확인하세요. IRSA 타임아웃/인증 이슈는 EKS IRSA에서 AssumeRoleWithWebIdentity 0s 타임아웃 해결 패턴과도 연결됩니다.

2) 버킷 정책(Bucket Policy): “Allow가 있어도 Deny가 이긴다”

S3 권한 문제의 절반은 명시적 Deny입니다. IAM 정책에 Allow가 있어도, 아래 중 하나가 있으면 403이 납니다.

  • 버킷 정책에 Deny가 존재
  • SCP(Organizations)에서 거부
  • Permission Boundary로 제한
  • Object ACL/Ownership 설정과 충돌

여기서는 버킷 정책을 중심으로 봅니다.

2-1. 가장 흔한 Deny 패턴 3가지

(1) TLS 강제 Deny

{
  "Sid": "DenyInsecureTransport",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ],
  "Condition": {
    "Bool": {"aws:SecureTransport": "false"}
  }
}
  • 프라이빗 네트워크/프록시에서 의도치 않게 HTTP로 나가면 403
  • ALB/프록시/SDK 설정 점검 필요

(2) 특정 VPC 엔드포인트만 허용 (aws:sourceVpce)

{
  "Sid": "DenyIfNotFromVPCE",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ],
  "Condition": {
    "StringNotEquals": {"aws:sourceVpce": "vpce-0123456789abcdef0"}
  }
}
  • VPC 밖(로컬/다른 VPC/다른 VPCE)에서 접근하면 무조건 403
  • 같은 VPC라도 라우팅이 인터넷/NAT로 빠지면 sourceVpce가 매칭되지 않습니다.

(3) 조직/계정 제한 (aws:PrincipalOrgID / aws:PrincipalAccount)

{
  "Sid": "DenyOutsideOrg",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ],
  "Condition": {
    "StringNotEquals": {"aws:PrincipalOrgID": "o-xxxxxxx"}
  }
}
  • 크로스어카운트 구성에서 특히 자주 발생

2-2. ListBucket vs GetObject: 리소스 ARN이 다르다

ListBucket버킷 ARN에 권한이 있어야 하고, GetObject/PutObject오브젝트 ARN에 권한이 있어야 합니다.

{
  "Effect": "Allow",
  "Action": ["s3:ListBucket"],
  "Resource": "arn:aws:s3:::my-bucket"
}
{
  "Effect": "Allow",
  "Action": ["s3:GetObject"],
  "Resource": "arn:aws:s3:::my-bucket/*"
}

aws s3 ls s3://my-bucket/는 되는데 특정 키만 403이면 오브젝트 레벨 정책/암호화(KMS)/소유권(ACL) 쪽을 의심해야 합니다.

2-3. S3 Object Ownership(ACL 비활성화)와 교차 계정 업로드

버킷이 Bucket owner enforced(ACL 비활성화)인데, 업로드 주체가 다른 계정/역할이면 소유권/권한 모델이 단순해지는 대신, 정책으로 명확히 열어줘야 합니다. 반대로 ACL 기반으로 억지로 풀려던 구성이 깨질 수 있습니다.

점검:

aws s3api get-bucket-ownership-controls --bucket my-bucket

3) SSE-KMS: S3 권한이 있어도 KMS에서 403이 난다

SSE-KMS(aws:kms)를 쓰면, S3 API는 통과해도 KMS 키 권한이 없어서 최종적으로 403/AccessDenied가 발생합니다. 특히 GetObjectDecrypt, PutObjectEncrypt/GenerateDataKey가 필요합니다.

3-1. 증상 패턴

  • 같은 버킷의 SSE-S3(AES256) 객체는 잘 읽히는데, SSE-KMS 객체만 403
  • CloudTrail에서 S3 이벤트는 AccessDenied인데, 동시에 KMS 이벤트에서도 Deny 흔적

3-2. 필요한 KMS 권한(최소 집합)

읽기(GetObject) 기준으로는 보통 아래가 필요합니다.

  • kms:Decrypt
  • (상황에 따라) kms:DescribeKey

쓰기(PutObject) 기준:

  • kms:Encrypt
  • kms:GenerateDataKey
  • kms:DescribeKey

중요: IAM 정책에 KMS 권한을 줘도, KMS Key policy가 허용하지 않으면 실패합니다. KMS는 “키 정책이 최종 관문”인 경우가 많습니다.

3-3. Key policy에서 S3 서비스/역할 허용 확인

대표적으로 같은 계정 내 역할이 접근해야 한다면, 키 정책에 해당 Role ARN이 들어가거나, 계정 루트에 위임(그리고 IAM에서 제어)하는 형태여야 합니다.

예시(개념용, 운영에서는 최소 권한/조건 추가 권장):

{
  "Sid": "AllowUseOfKeyForAppRole",
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::123456789012:role/my-app-role"},
  "Action": [
    "kms:Decrypt",
    "kms:Encrypt",
    "kms:GenerateDataKey",
    "kms:DescribeKey"
  ],
  "Resource": "*"
}

3-4. KMS 조건으로 S3 경유만 허용하는 경우(서비스 제약)

보안을 위해 kms:ViaService 또는 encryption context 조건을 걸어두면, “직접 KMS 호출은 금지, S3를 통해서만 허용” 같은 정책이 됩니다. 이때 리전 불일치나 서비스 경유 조건이 맞지 않으면 403이 납니다.

점검 포인트:

  • 버킷 리전과 KMS 키 리전이 동일한지
  • kms:ViaServices3.<region>.amazonaws.com로 맞는지

3-5. 빠른 확인 커맨드

객체의 KMS 사용 여부 확인:

aws s3api head-object --bucket my-bucket --key path/to/object.json

출력에 아래가 보이면 SSE-KMS입니다.

  • ServerSideEncryption: "aws:kms"
  • SSEKMSKeyId: "..."

4) VPC 엔드포인트(S3 VPCE): 정책, 라우팅, DNS가 403을 만든다

사설망에서 S3 접근을 강제하면 흔히 **Gateway Endpoint(com.amazonaws.<region>.s3)**를 씁니다. 여기서 403은 두 갈래로 나뉩니다.

  • 버킷 정책이 VPCE를 강제하는데, 트래픽이 VPCE로 안 나감 → 403
  • VPCE Endpoint policy가 더 좁게 막음 → 403

4-1. 라우팅 테이블에서 S3 PrefixList가 VPCE로 가는지

Gateway Endpoint는 라우팅 테이블에 S3 PrefixList 경로가 있어야 합니다.

점검 순서:

  1. 해당 서브넷이 연결된 Route Table 확인
  2. pl-xxxx(S3 PrefixList) 대상이 vpce-xxxx인지 확인

CLI 예(개념):

aws ec2 describe-route-tables --route-table-ids rtb-xxxxxxxx

4-2. Endpoint policy가 S3 액션/리소스를 제한하는지

VPCE 정책이 s3:* 전체를 허용하지 않고 특정 버킷/프리픽스만 허용하면, 그 외 요청은 403이 납니다.

예시(특정 버킷만 허용):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-bucket/*"
    }
  ]
}

여기서 ListBucket이 필요하면 버킷 ARN도 추가해야 합니다.

4-3. 버킷 정책의 sourceVpce와 “실제 VPCE” 불일치

멀티 VPC/멀티 엔드포인트(예: dev/prod) 환경에서 버킷 정책에 aws:sourceVpce를 하나만 넣어두고, 다른 엔드포인트에서 접근하면 403이 납니다.

해결은 보통 둘 중 하나입니다.

  • 버킷 정책에 허용할 VPCE를 복수로 등록
  • 환경별로 버킷을 분리

4-4. Private DNS/Interface Endpoint 혼동

S3는 일반적으로 Gateway Endpoint를 쓰지만, 특정 요구(예: 온프레에서 PrivateLink, 혹은 특정 서비스 조합)로 Interface Endpoint를 붙이는 경우도 있습니다. 이때 DNS 해석/프록시 경유로 경로가 달라져 aws:sourceVpce 조건이 깨질 수 있습니다.

5) “빠르게” 원인을 좁히는 실전 체크리스트(10분 루틴)

아래 순서로 보면 불필요한 삽질을 줄일 수 있습니다.

5-1. 호출 주체 확정

  • 애플리케이션이 실제로 사용하는 Role ARN 확인(AssumedRole 세션명 포함)
  • EKS라면 ServiceAccount ↔ Role 매핑(IRSA) 확인

5-2. 어떤 API가 403인지 확인

  • ListBucket인지 GetObject인지에 따라 정책 리소스가 다름

5-3. CloudTrail로 “Deny 지점” 찾기

  • S3 이벤트: 어떤 버킷/키/조건에서 거부?
  • KMS 이벤트: Decrypt/Encrypt 거부 흔적?

5-4. 버킷 정책에서 Deny 조건부터 검색

  • aws:SecureTransport
  • aws:sourceVpce
  • aws:PrincipalOrgID / aws:PrincipalAccount
  • IP 제한(aws:SourceIp)이 NAT/프록시로 바뀌며 불일치하는지

5-5. SSE-KMS면 KMS 키 정책/권한 확인

  • 객체가 SSE-KMS인지(head-object)
  • 해당 Role이 키 정책에서 허용되는지
  • kms:ViaService/리전 불일치 없는지

5-6. VPCE 강제 환경이면 라우팅/엔드포인트 정책 확인

  • Route Table에 S3 PrefixList → VPCE 라우트 존재?
  • VPCE policy가 필요한 액션/리소스를 포함?

6) 재현/검증용 커맨드 모음

6-1. 동일 자격증명으로 S3 API별 테스트

# 버킷 목록(버킷 레벨)
aws s3api list-objects-v2 --bucket my-bucket --max-keys 1

# 객체 헤더(권한/암호화 단서)
aws s3api head-object --bucket my-bucket --key path/to/object

# 다운로드(GetObject)
aws s3 cp s3://my-bucket/path/to/object /tmp/object

6-2. 요청이 VPCE를 타는지 간접 확인(조건 기반)

버킷 정책에 aws:sourceVpce 강제가 걸려 있다면,

  • VPCE 경유: 성공
  • NAT/인터넷 경유: 403

즉, 동일 자격증명으로 VPC 내부/외부에서 테스트하면 네트워크 경로 문제인지 빠르게 분리됩니다.

6-3. KMS 키 메타 확인

aws kms describe-key --key-id arn:aws:kms:ap-northeast-2:123456789012:key/....

> Disabled 상태거나, 다른 리전 키를 참조하면 의외로 단순한데 찾기 어렵습니다.

7) 운영에서 자주 하는 실수와 예방책

7-1. “S3는 열었는데 KMS를 안 열었다”

SSE-KMS는 S3 권한만으로 끝나지 않습니다. S3 정책 변경 시 KMS 키 정책도 같이 코드로 관리(Terraform/CloudFormation)하는 게 안전합니다.

7-2. 버킷 정책에 Deny를 먼저 넣고 예외를 나중에 추가

Deny 우선순위 때문에 예외 Allow가 먹지 않는 형태가 자주 나옵니다. Deny 조건을 쓸 때는:

  • 조건 키가 실제 트래픽에서 항상 기대값을 갖는지
  • 멀티 VPCE/멀티 계정/멀티 리전 확장 시 깨지지 않는지

를 먼저 검증하세요.

7-3. VPCE 정책을 최소화하다가 필요한 API를 누락

예를 들어 앱은 GetObject만 한다고 생각했는데, SDK가 내부적으로 HeadObject를 먼저 치거나, 특정 라이브러리가 ListBucket을 호출하는 경우가 있습니다. 403이 특정 라이브러리/버전에서만 나면 이 케이스를 의심하세요.

네트워크 경유/타임아웃과 섞여 보이면, 장애가 403이 아니라 5xx/timeout로도 확장될 수 있습니다. 경계면 장애 대응 패턴은 AWS ALB 502·504 난사 - 원인별 해결 체크리스트도 함께 보면 좋습니다.

마무리

S3 403 AccessDenied는 “권한 하나 추가”로 끝나는 문제처럼 보여도, 실제로는 (1) 버킷 정책의 Deny/조건, (2) SSE-KMS의 키 정책/권한, (3) VPC 엔드포인트 정책·라우팅의 3축에서 가장 많이 발생합니다.

가장 효율적인 접근은:

  1. CloudTrail로 호출 주체/막힌 API를 확정하고,
  2. 버킷 정책의 Deny 조건을 먼저 제거/수정 후보로 좁힌 뒤,
  3. SSE-KMS라면 KMS 키 정책까지 함께 검증하고,
  4. VPCE 강제 환경이면 라우팅과 VPCE policy까지 확인

하는 순서입니다. 이 루틴만 잡아도 “왜 403인지 감으로 때려맞추는” 시간을 크게 줄일 수 있습니다.