Published on

AWS S3 AccessDenied 403 - IAM·버킷정책·KMS 점검

Authors

서버나 배치에서 S3를 호출하다가 AccessDenied403 을 만나면, 대부분은 “권한 부족”으로 뭉뚱그려 생각합니다. 하지만 S3의 권한 결정은 IAM 정책, 버킷 정책, ACL/오브젝트 소유권, 퍼블릭 액세스 차단(PAB), SSE-KMS 키 정책까지 겹치며, 여기에 VPC Endpoint 정책이나 Organizations SCP 같은 상위 정책이 더해지면 원인 추적이 급격히 어려워집니다.

이 글은 “무엇을 어디서부터 확인해야 하는지”를 재현 가능하고 로그 기반으로 정리한 체크리스트입니다. 특히 403 AccessDeniedGetObject 에서 나는지, PutObject 에서 나는지, 혹은 ListBucket 에서 나는지에 따라 필요한 권한이 다르므로, API 단위로 분해해서 접근합니다.

1) 먼저: 어떤 API가 막혔는지부터 고정하기

S3 403은 동일해 보여도, 실제로는 서로 다른 권한 세트가 필요합니다.

  • s3:ListBucket버킷 ARN 에 적용됩니다: arn:aws:s3:::my-bucket
  • s3:GetObject, s3:PutObject오브젝트 ARN 에 적용됩니다: arn:aws:s3:::my-bucket/path/to/key

CLI로 문제를 최소 재현해 보세요.

aws s3api head-object --bucket my-bucket --key path/to/key
aws s3api get-object --bucket my-bucket --key path/to/key /tmp/out
aws s3api list-objects-v2 --bucket my-bucket --max-items 1

head-object 가 막히면 대개 s3:GetObject 또는 KMS 권한 이슈가 많고, list-objects-v2 만 막히면 s3:ListBucket 가 빠진 경우가 흔합니다.

CloudTrail 이벤트로 “정확히” 확인

가장 빠른 방법은 CloudTrail에서 해당 시간대 이벤트를 보는 것입니다.

  • Event source: s3.amazonaws.com
  • Error code: AccessDenied
  • Event name: GetObject, PutObject, ListBucket, HeadObject

이때 requestParameters.bucketNamerequestParameters.key 를 보면 “버킷 레벨”인지 “오브젝트 레벨”인지 확정할 수 있습니다.

2) IAM 정책: Allow가 있어도 Deny가 있으면 끝

S3 권한 평가는 “명시적 Deny가 최우선”입니다. 즉, 아래 중 하나라도 Deny면 403이 납니다.

  • IAM 사용자/역할 정책의 Deny
  • Permission Boundary의 Deny
  • Organizations SCP의 Deny
  • 버킷 정책의 Deny
  • VPC Endpoint 정책의 Deny

IAM에서 자주 빠지는 권한 조합

예: 특정 prefix만 리스트하고 객체를 읽고 싶을 때

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucketWithPrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-bucket",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["images/*"]
        }
      }
    },
    {
      "Sid": "ReadObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-bucket/images/*"
    }
  ]
}

포인트는 ListBucket버킷 ARN, GetObject오브젝트 ARN 이라는 점입니다. 이 매핑이 틀리면 계속 403이 납니다.

STS AssumeRole 환경이면 “누가 호출했는지”를 확인

ECS/EKS/EC2에서 역할을 쓰는 경우, 코드에 박힌 자격증명이 아니라 실제 실행 환경의 Role 이 호출합니다.

aws sts get-caller-identity

여기서 나온 Arn 이 기대한 역할인지부터 확인하세요.

3) 버킷 정책: 조건(Condition) 때문에 막히는 경우가 매우 흔함

버킷 정책은 IAM Allow가 있어도 추가로 막을 수 있습니다. 특히 아래 조건이 자주 원인입니다.

  • aws:PrincipalArn 또는 aws:userid 조건이 불일치
  • aws:SourceVpce (VPC Endpoint 강제)
  • aws:SourceIp (고정 IP만 허용)
  • s3:x-amz-server-side-encryption (SSE 강제)
  • s3:signatureversion 또는 TLS 강제

예: 특정 VPC Endpoint에서만 접근 허용

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyWithoutVPCEndpoint",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:SourceVpce": "vpce-abc123"
        }
      }
    }
  ]
}

이 경우 로컬 개발 환경이나 다른 네트워크에서 호출하면 무조건 403입니다.

퍼블릭 액세스 차단(PAB)과의 상호작용

버킷 정책에서 퍼블릭을 열어두었더라도, S3의 Block Public Access 설정이 켜져 있으면 의도와 다르게 차단될 수 있습니다.

  • BlockPublicAcls
  • IgnorePublicAcls
  • BlockPublicPolicy
  • RestrictPublicBuckets

특히 BlockPublicPolicy 가 켜져 있으면 “퍼블릭 허용 버킷 정책”이 무시되어 403처럼 보일 수 있습니다.

4) Object Ownership / ACL: 크로스 어카운트에서 403의 핵심

S3는 과거에 ACL이 중요한 축이었고, 지금도 크로스 어카운트 업로드에서는 ACL/소유권이 403의 주요 원인입니다.

대표 시나리오:

  • A 계정이 버킷 소유자
  • B 계정이 PutObject 로 업로드
  • 업로드된 객체의 소유자가 B가 되어버림
  • A 계정(버킷 소유자)이 GetObject 하려다 403

해결의 정석은 Bucket owner enforced 를 사용하는 것입니다.

  • S3 콘솔: Object Ownership Bucket owner enforced
  • 결과: ACL 비활성화, 버킷 소유자가 객체 소유

이미 운영 중이라면 영향 범위를 먼저 확인해야 합니다.

5) SSE-KMS를 쓰면: S3 권한만으로는 절대 안 됨

x-amz-server-side-encryption: aws:kms 로 암호화된 객체는, 읽기/쓰기 시점에 KMS 권한이 추가로 필요합니다.

읽기(GetObject)에서 필요한 것

  • S3: s3:GetObject
  • KMS: kms:Decrypt (그리고 보통 kms:DescribeKey)

쓰기(PutObject)에서 필요한 것

  • S3: s3:PutObject
  • KMS: kms:Encrypt, kms:GenerateDataKey (그리고 보통 kms:DescribeKey)

즉, IAM에 S3 Allow만 넣어도 403이 납니다.

KMS 키 정책(Key policy)이 “진짜 최종 관문”

KMS는 IAM 정책만으로 끝나지 않고, 키 정책이 해당 Principal을 신뢰해야 합니다.

예: 특정 역할에 복호화 허용

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowDecryptForAppRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/my-app-role"
      },
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": "*"
    }
  ]
}

또한, 버킷 정책에서 SSE-KMS를 강제해놓고 클라이언트가 SSE-S3로 업로드하면, PutObject 가 403으로 막힐 수 있습니다.

KMS 관련 403을 빠르게 판별하는 법

CloudTrail에서 S3 이벤트뿐 아니라 KMS 이벤트도 같이 보세요.

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

S3 403이지만 실은 KMS Deny인 경우가 많습니다.

6) VPC Endpoint(S3 Gateway/Interface) 정책도 별도의 “필터”

사설망에서 S3를 쓰는 구성(EKS 프라이빗, EC2 프라이빗)에서는 VPC Endpoint 정책이 IAM/버킷 정책 위에 한 겹 더 생깁니다.

예: 특정 버킷만 허용하는 Endpoint 정책

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

여기서 PutObject 가 누락되어 있으면 업로드만 403이 납니다.

7) 실전 디버깅 순서(가장 빠른 루트)

운영에서 시간을 아끼려면 아래 순서가 효율적입니다.

  1. 호출 주체 확정: aws sts get-caller-identity 로 실제 Role/User 확인
  2. CloudTrail로 실패한 API 확정: GetObject 인지 ListBucket 인지 확정
  3. S3 권한 매핑 확인: 버킷 ARN vs 오브젝트 ARN 리소스 범위가 맞는지
  4. 명시적 Deny 탐색: 버킷 정책, IAM, SCP, Permission Boundary, Endpoint 정책
  5. 퍼블릭 액세스 차단(PAB) 확인: 정책이 의도대로 먹는지
  6. SSE-KMS 여부 확인: 객체/버킷의 기본 암호화가 KMS인지, KMS 키 정책/권한 확인
  7. 크로스 어카운트 소유권/ACL 확인: Object Ownership 설정과 기존 객체 소유자

이 흐름대로 보면 “감”이 아니라 “증거”로 403을 좁힐 수 있습니다.

8) 재현용 최소 예제: Node.js에서 흔한 실수 2가지

(1) ListBucket 없이 prefix 조회 시도

SDK가 내부적으로 ListObjectsV2 를 호출하면 s3:ListBucket 이 필요합니다.

import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "ap-northeast-2" });

const res = await s3.send(
  new ListObjectsV2Command({
    Bucket: "my-bucket",
    Prefix: "images/",
    MaxKeys: 10,
  })
);
console.log(res.Contents?.map((o) => o.Key));

권한은 반드시 s3:ListBucket 를 버킷 ARN에 줘야 합니다.

(2) SSE-KMS 버킷에 PutObject 하는데 KMS 권한 누락

버킷 기본 암호화가 SSE-KMS면, 애플리케이션 Role에 KMS 권한이 필요합니다.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "ap-northeast-2" });

await s3.send(
  new PutObjectCommand({
    Bucket: "my-bucket",
    Key: "uploads/a.txt",
    Body: "hello",
  })
);

이 코드 자체는 문제 없어도, KMS의 kms:GenerateDataKey 가 없으면 403이 날 수 있습니다.

9) 체크리스트 요약

  • 403 AccessDenied 는 “S3만”의 문제가 아닐 수 있음
  • API별로 필요한 권한이 다름: ListBucket 은 버킷 ARN, GetObject 는 오브젝트 ARN
  • Deny가 하나라도 있으면 끝: IAM, 버킷 정책, SCP, Endpoint 정책 모두 확인
  • SSE-KMS면 KMS 권한과 키 정책이 필수
  • 크로스 어카운트 업로드는 Object Ownership 설정이 핵심

10) 함께 읽으면 좋은 트러블슈팅 글

권한/정책 이슈처럼 원인이 여러 층에 걸친 문제는 “증상 고정 → 관측(로그) → 범위 축소” 패턴이 중요합니다. 같은 방식으로 접근하는 트러블슈팅 사례로 아래 글도 참고할 만합니다.


운영에서 가장 자주 시간을 잡아먹는 케이스는 “IAM에는 Allow가 있는데 왜 403이지?”입니다. 그때는 거의 항상 버킷 정책의 조건, VPC Endpoint 정책, SSE-KMS 키 정책, SCP/Boundary의 Deny 중 하나가 숨어 있습니다. CloudTrail로 실패 이벤트를 고정하고, 위 순서대로 한 겹씩 벗기면 재현과 해결이 가능합니다.