Published on

S3 AccessDenied 403 급발생 - OAC·정책·KMS 30분 진단

Authors

서버나 애플리케이션 코드는 그대로인데, 어느 순간부터 S3 요청이 403 AccessDenied로 바뀌면 대부분은 권한 경로(Principal/Policy/KMS/조건/캐시) 중 하나가 바뀐 것입니다. 문제는 “어디가 바뀌었는지”를 감으로 찾기 시작하면 2~3시간이 순식간에 날아간다는 점입니다.

이 글은 CloudFront(OAC/OAI) + S3(버킷 정책/퍼블릭 액세스 차단) + KMS(SSE-KMS) 조합에서 흔히 터지는 403을 30분 안에 좁히는 체크리스트입니다. (특히 갑자기 터졌다는 전제: 배포/정책 변경/키 로테이션/캐시 만료 같은 이벤트가 있었을 확률이 높습니다.)

> 참고로 “403이 계속 난다”는 증상 자체는 WAF/봇 차단 같은 다른 레이어에서도 발생합니다. CloudFront 앞단에 WAF가 붙어 있다면 AWS WAF Bot Control 막힘으로 403 지속될 때도 함께 확인하세요.


0) 30분 타임박스 진단 플로우(결론부터)

아래 순서대로 보면, 대개 10~30분 안에 범인을 특정할 수 있습니다.

  1. 요청 경로 분리: 403이 S3 직접 호출인지, CloudFront 경유인지 분리
  2. CloudFront/OAC 확인: OAC 설정/원본 요청 정책/서명 전달 여부
  3. S3 버킷 정책 확인: Principal/Action/Resource/Condition(특히 AWS:SourceArn, AWS:SourceAccount)
  4. S3 Public Access Block/ACL 확인: “갑자기”는 여기서도 많이 납니다
  5. SSE-KMS 여부 확인: KMS 키 정책 + CloudFront 서비스 프린시펄/역할 권한
  6. CloudTrail로 거짓말 잡기: 실제 Deny 주체(Policy vs KMS vs 조건 불일치)
  7. 캐시/배포 이슈: CloudFront 캐시된 403, 배포 미반영, 멀티 리전/멀티 계정 혼선

1) 먼저: 403이 “어디서” 나는지 분리하기

1-1. S3 직접 호출로 재현

S3 URL(가상 호스팅/경로 스타일)로 직접 접근해봅니다.

# 객체가 퍼블릭/프라이빗 여부와 무관하게, 현재 상태를 빠르게 확인
curl -I "https://YOUR_BUCKET.s3.amazonaws.com/path/to/object.jpg"

# 리전 엔드포인트를 명시(리다이렉트/서명 이슈를 줄임)
curl -I "https://YOUR_BUCKET.s3.ap-northeast-2.amazonaws.com/path/to/object.jpg"
  • S3 직접도 403이면: 버킷 정책, 퍼블릭 액세스 차단, KMS, 객체 소유권/ACL 쪽이 유력
  • S3 직접은 OK인데 CloudFront만 403이면: OAC/OAI, 원본 요청 정책, 서명 전달, CloudFront 설정이 유력

1-2. CloudFront 경유 요청으로 재현

curl -I "https://d123456abcdef8.cloudfront.net/path/to/object.jpg"

CloudFront 응답 헤더에 아래가 있으면 분기 힌트가 됩니다.

  • x-cache: Error from cloudfront → 원본에서 에러 받음
  • Via: 1.1 ... cloudfront / X-Amz-Cf-Id → CloudFront 경유 확실

2) CloudFront OAC/OAI에서 갑자기 403 나는 대표 원인

OAC(Origin Access Control)는 OAI보다 “요즘 표준”이지만, 설정이 조금만 어긋나도 S3는 즉시 AccessDenied를 줍니다.

2-1. OAC이 붙었는데 버킷 정책이 OAI를 바라보는 경우

이전에는 OAI로 쓰다가 OAC로 바꿨는데, 버킷 정책이 그대로 OAI Canonical User ID 기반이면 CloudFront가 S3에 접근 못 합니다.

  • OAI 기반 정책: Principal이 CanonicalUser 형태
  • OAC 기반 정책: Principal이 Service: cloudfront.amazonaws.com + Condition: AWS:SourceArn 형태

2-2. OAC은 붙었는데 “서명(Signing)”이 꺼져 있거나 잘못된 경우

CloudFront 콘솔에서 OAC 설정에 보통 아래 옵션이 있습니다.

  • Signing behavior: Sign requests (recommended)
  • Signing protocol: SigV4

여기서 서명이 안 붙으면 S3는 “익명 요청”으로 취급하고, 버킷이 프라이빗이면 403입니다.

2-3. CloudFront 배포/원본 요청 정책(Origin Request Policy) 문제

특정 헤더/쿼리스트링을 원본으로 전달해야 하는 구조(예: presigned URL, 커스텀 인증)라면 원본 요청 정책이 바뀌면서 403이 날 수 있습니다.

다만 OAC로 S3에 접근하는 정적 파일은 보통 추가 헤더 전달이 필요 없고, 대부분은 버킷 정책 조건 불일치가 원인입니다.


3) S3 버킷 정책: OAC 표준 템플릿부터 검증

OAC 사용 시, 가장 안전한 출발점은 아래 형태입니다.

3-1. OAC용 최소 버킷 정책 예시

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipalReadOnly",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::YOUR_BUCKET/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
        }
      }
    }
  ]
}

여기서 “갑자기”를 만드는 포인트

  • 배포를 새로 만들었는데 distribution ID가 바뀜 → AWS:SourceArn 불일치로 즉시 403
  • 멀티 계정/멀티 리전에서 Account ID 혼동 → AWS:SourceArn 또는 SourceAccount 불일치
  • Resourcearn:aws:s3:::bucket/path/*처럼 좁혀놨는데 실제 요청 경로가 바뀜

3-2. ListBucket가 필요한데 GetObject만 허용한 경우

웹에서 단일 객체 접근은 GetObject로 충분하지만, 어떤 SDK/라이브러리는 사전 확인으로 ListBucket을 호출하기도 합니다.

필요하다면 다음을 추가합니다.

{
  "Sid": "AllowListBucket",
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:ListBucket",
  "Resource": "arn:aws:s3:::YOUR_BUCKET",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
    }
  }
}

4) S3 Public Access Block / Object Ownership / ACL: “어제까지 됐는데 오늘 403”의 단골

4-1. Public Access Block가 켜지면서 ACL/퍼블릭 정책이 무력화

조직 단위(SCP)나 보안 가드레일로 다음이 바뀌는 경우가 많습니다.

  • BlockPublicAcls
  • IgnorePublicAcls
  • BlockPublicPolicy
  • RestrictPublicBuckets

퍼블릭 객체를 ACL로 열어두던 레거시 구조면, 어느 날 갑자기 403이 될 수 있습니다.

확인:

aws s3api get-public-access-block --bucket YOUR_BUCKET

4-2. Object Ownership(Bucket owner enforced) 변경으로 ACL이 무시

Bucket owner enforced가 켜지면 ACL은 비활성화됩니다. 외부 계정이 업로드한 객체를 ACL로 공유하던 패턴이면 갑자기 접근이 막힙니다.

확인:

aws s3api get-bucket-ownership-controls --bucket YOUR_BUCKET

5) SSE-KMS가 걸린 S3: “버킷 정책은 맞는데도 403”의 핵심

S3 객체가 SSE-KMS로 암호화되어 있으면, s3:GetObject 권한만으로는 부족합니다. 복호화 과정에서 KMS 권한이 추가로 필요합니다.

5-1. 증상 패턴

  • CloudFront → S3 원본 접근은 되는데 특정 객체만 403
  • 동일 버킷 내에서도 SSE-S3는 OK, SSE-KMS만 403
  • CloudTrail에서 S3는 AccessDenied, 혹은 KMS Decrypt가 Denied

5-2. KMS 키 확인

aws s3api head-object --bucket YOUR_BUCKET --key path/to/object.jpg \
  --query "ServerSideEncryption"

aws s3api head-object --bucket YOUR_BUCKET --key path/to/object.jpg \
  --query "SSEKMSKeyId"

5-3. KMS 키 정책(또는 키에 연결된 권한)에서 빠지는 것들

CloudFront OAC로 S3에 접근하는 경우, 실제로 KMS를 호출하는 주체는 상황에 따라 다르게 보일 수 있습니다(서비스 연동/역할/요청 경로). 실무에서 가장 안전한 접근은:

  • S3가 KMS를 사용할 수 있도록(S3 서비스가 해당 키를 통해 객체를 복호화할 수 있도록) 키 정책/권한을 구성
  • 필요 시 조건으로 버킷/계정/암호화 컨텍스트를 제한해서 과도한 오픈을 피함

예시(개념 템플릿: 실제 환경에 맞게 최소화/조건 강화 필요):

{
  "Sid": "AllowS3UseOfTheKeyForSpecificBucket",
  "Effect": "Allow",
  "Principal": { "Service": "s3.amazonaws.com" },
  "Action": [
    "kms:Decrypt",
    "kms:Encrypt",
    "kms:ReEncrypt*",
    "kms:GenerateDataKey*",
    "kms:DescribeKey"
  ],
  "Resource": "*",
  "Condition": {
    "StringEquals": {
      "aws:SourceAccount": "YOUR_ACCOUNT_ID"
    },
    "StringLike": {
      "aws:SourceArn": "arn:aws:s3:::YOUR_BUCKET"
    }
  }
}

> 주의: KMS 조건 키/컨텍스트는 구성에 따라 달라질 수 있습니다. 가장 확실한 방법은 **CloudTrail에서 KMS 이벤트를 보고 “누가 어떤 Action을 Deny 당했는지”**를 확인한 뒤, 그 주체와 조건에 맞춰 최소 권한으로 조정하는 것입니다.


6) CloudTrail로 403의 “진짜 Deny 지점” 5분 만에 찾기

403을 눈으로만 보면 S3가 거절한 것처럼 보이지만, 실제로는:

  • 버킷 정책의 조건 불일치
  • 명시적 Deny(조직 SCP/Permission Boundary 포함)
  • KMS Decrypt Deny

중 하나인 경우가 많습니다.

6-1. S3 데이터 이벤트를 켰다면 바로 필터

CloudTrail Lake/이벤트 히스토리에서:

  • Event source: s3.amazonaws.com
  • Event name: GetObject
  • Error code: AccessDenied

을 찾고, userIdentityrequestParameters를 봅니다.

6-2. KMS 이벤트도 같이 확인

  • Event source: kms.amazonaws.com
  • Event name: Decrypt
  • Error code: AccessDenied

S3는 결국 “복호화 못 해서 실패”인데, 표면상은 S3 403으로 보이는 케이스가 있습니다.


7) CloudFront 캐시된 403 / 배포 미전파: 고쳤는데도 계속 403일 때

7-1. CloudFront가 403을 캐시할 수 있음

정책을 고쳤는데도 계속 403이면, CloudFront가 에러 응답을 캐시하고 있을 수 있습니다.

대응:

  • 해당 경로 무효화(invalidation)
  • 에러 캐싱 TTL 확인
aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/path/to/object.jpg" "/static/*"

7-2. 멀티 배포/멀티 오리진에서 “다른 배포”를 고친 경우

특히 스테이징/프로덕션 배포가 비슷한 도메인 구조를 가지면, 잘못된 Distribution을 수정하는 실수가 잦습니다.

  • 실제 요청의 X-Amz-Cf-Id로 배포를 역추적
  • 도메인의 CNAME이 어느 배포를 가리키는지 재확인

8) 실전: 30분 진단 체크리스트(복붙용)

아래를 위에서부터 체크하면 됩니다.

8-1. 1단계(5분): 재현/분리

  • S3 직접 curl -I는 200/403?
  • CloudFront curl -I는 403?
  • 특정 객체만 403인가, 전부 403인가?

8-2. 2단계(10분): OAC/버킷 정책

  • CloudFront 원본이 S3이고 OAC가 연결되어 있는가?
  • 버킷 정책 Principal이 cloudfront.amazonaws.com인가?
  • AWS:SourceArn의 distribution ID가 정확한가?
  • Resource 경로가 실제 키 prefix와 일치하는가?

8-3. 3단계(10분): Public Access Block/Ownership/ACL

  • Public Access Block 설정이 최근 바뀌지 않았나?
  • Object Ownership가 Bucket owner enforced로 바뀌지 않았나?
  • ACL 기반 퍼블릭/공유 모델을 쓰고 있진 않나?

8-4. 4단계(5분~): KMS

  • 객체가 SSE-KMS인가?
  • KMS 키 정책에 필요한 주체가 Decrypt 가능한가?
  • CloudTrail에서 KMS AccessDenied가 보이는가?

9) 자주 하는 실수 6가지(원인별로 바로 잡기)

  1. OAI 정책을 OAC로 착각: OAC는 Service Principal + SourceArn 조건이 핵심
  2. 배포 새로 만들고 SourceArn 미갱신: “갑자기”의 1순위
  3. RestrictPublicBuckets 켜짐: 퍼블릭 버킷/ACL은 즉시 영향
  4. SSE-KMS를 켰는데 KMS 권한 미구성: 버킷 정책만으론 해결 안 됨
  5. CloudFront 403 캐시: 고쳤는데도 계속 403이면 무효화
  6. 다른 계정/다른 배포 수정: 멀티 환경에서 흔한 인적 오류

10) 마무리: “403은 권한 문제”가 아니라 “경로 문제”다

S3 AccessDenied 403은 결국 요청 주체(Principal)가 무엇이고, 어떤 정책/조건을 통과해야 하며, KMS 복호화까지 포함한 전체 경로가 열려 있는지의 문제입니다. 특히 OAC + SourceArn 조건은 안전하지만, 배포/계정/리소스가 조금만 바뀌어도 바로 403이 납니다.

장애 대응 관점에서는 “추측해서 정책을 더 열기”보다, CloudTrail로 Deny 지점을 먼저 특정하고 최소 변경으로 복구하는 게 가장 빠르고 안전합니다.

비슷한 방식으로 ‘에러를 원인별로 잘라서’ 체크리스트로 줄이는 접근이 필요하다면, 인프라 에러 대응 글로 AWS ALB 502·504 난사 - 원인별 해결 체크리스트도 함께 참고하면 문제 분리 감각을 잡는 데 도움이 됩니다.