- Published on
AWS S3 AccessDenied(403) 원인 10가지 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 클라이언트에서 S3 요청이 403 AccessDenied로 떨어지면 대부분 “권한이 없나?”로만 생각하고 IAM 정책만 들여다보다가 시간을 많이 씁니다. 하지만 S3는 권한 평가 레이어가 여러 겹이고(Identity 정책, Resource 정책, 퍼블릭 차단, 조직 정책, KMS, 네트워크 정책 등), 그중 하나라도 Deny가 걸리면 최종 결과는 AccessDenied가 됩니다.
이 글은 운영 중 자주 맞닥뜨리는 AccessDenied(403)의 대표 원인 10가지를 “어디서 확인하고 어떻게 고칠지” 중심으로 정리한 점검표입니다. (CloudFront, ALB, SDK, CLI 등 접근 경로가 달라도 원리는 동일합니다.)
0) 먼저: 어떤 403인지 로그로 분류하기
S3의 403은 원인이 다양하므로, 증거부터 모으는 게 빠릅니다.
- S3 서버 액세스 로그 또는 CloudTrail Data events(오브젝트 레벨)에서
errorCode가AccessDenied인지userIdentity가 누구인지(역할, 세션, assumed-role)- 어떤 API인지(
GetObject,ListBucket,PutObject,HeadObject등) - 어떤 조건 키에서 막혔는지(
aws:SourceVpce,aws:PrincipalOrgID,s3:prefix등)
CloudTrail에서 흔히 보는 단서
eventSource:s3.amazonaws.comeventName:GetObject또는ListBucketerrorCode:AccessDeniedresources: 버킷 ARN, 오브젝트 ARN
이 단서를 기반으로 아래 10가지를 순서대로 체크하면 대부분 해결됩니다.
1) IAM(Identity) 정책에 필요한 Action/Resource가 빠짐
가장 기본이지만 여전히 1순위입니다. 특히 ListBucket과 GetObject는 리소스 ARN이 다릅니다.
- 버킷 목록 조회:
s3:ListBucket은arn:aws:s3:::my-bucket - 오브젝트 읽기:
s3:GetObject는arn:aws:s3:::my-bucket/*
예시: 읽기 + 특정 prefix만 List 허용
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListBucketForPrefix",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::my-bucket",
"Condition": {
"StringLike": {
"s3:prefix": ["public/*"]
}
}
},
{
"Sid": "AllowGetObject",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-bucket/public/*"
}
]
}
ListBucket 권한 없이 SDK가 내부적으로 ListObjectsV2를 호출하거나, 콘솔이 prefix 탐색을 위해 List를 호출하면 403이 납니다.
2) 버킷 정책(Resource policy)에서 Deny가 걸림
S3 권한 평가는 명시적 Deny가 최우선입니다. IAM에서 Allow가 있어도 버킷 정책에 Deny가 있으면 무조건 막힙니다.
특히 아래 조건 기반 Deny가 흔합니다.
- 특정 VPC 엔드포인트만 허용(
aws:SourceVpce) - 특정 IP만 허용(
aws:SourceIp) - HTTPS 강제(
aws:SecureTransport) - 특정 Org/Account만 허용(
aws:PrincipalOrgID,aws:PrincipalAccount)
예시: HTTPS 아니면 전부 Deny
{
"Version": "2012-10-17",
"Statement": [
{
"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이 됩니다.
3) S3 Block Public Access 설정이 정책을 무력화
퍼블릭 공개를 의도했는데도 403이면, 버킷의 Block Public Access가 정책을 차단하고 있을 수 있습니다.
BlockPublicAclsIgnorePublicAclsBlockPublicPolicyRestrictPublicBuckets
이 옵션은 “의도치 않은 공개”를 막는 강력한 안전장치라서, 버킷 정책에 Principal이 *로 열려 있어도 차단될 수 있습니다.
CLI로 확인
aws s3api get-public-access-block --bucket my-bucket
퍼블릭 정적 호스팅을 하려면, 공개 범위를 최소화한 뒤(특정 prefix만) 필요한 항목만 조정하세요. 운영 환경에서는 CloudFront OAC/OAI로 우회 공개를 권장합니다.
4) KMS(SSE-KMS) 암호화 오브젝트인데 KMS 권한이 없음
S3 권한이 충분해도, 오브젝트가 SSE-KMS로 암호화되어 있으면 KMS 키에 대한 권한이 별도로 필요합니다.
증상
GetObject가403또는AccessDenied로 실패- CloudTrail에서 KMS 관련
AccessDenied가 함께 보이기도 함
필요 권한(대표)
kms:Decryptkms:Encrypt(업로드 시)kms:GenerateDataKey
또한 KMS 키 정책(Key policy)이 Principal을 허용해야 합니다. IAM 정책만으로는 부족할 수 있습니다.
예시: KMS 권한을 역할에 부여
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:ap-northeast-2:123456789012:key/abcd-efgh-..."
}
]
}
5) 조직 정책(SCP) 또는 Permission Boundary에서 차단
AWS Organizations를 쓰면 SCP가 상위에서 Deny를 걸 수 있고, IAM Permission Boundary가 역할 권한의 상한선을 제한할 수 있습니다.
특징
- IAM 역할/사용자 정책을 아무리 수정해도 해결이 안 됨
- 특정 계정/OU에서만 재현
점검 포인트
- 해당 계정이 속한 OU의 SCP
- Role에 Permission boundary가 붙어 있는지
SCP는 “계정이 할 수 있는 최대치”를 제한하므로, S3 전체를 막아놓은 보안 OU에서는 버킷 정책을 열어도 접근이 안 됩니다.
6) VPC Endpoint(게이트웨이/인터페이스) 정책으로 막힘
프라이빗 네트워크에서 S3로 접근할 때 VPC Endpoint를 사용하면, Endpoint policy가 추가 필터로 동작합니다.
흔한 실수
- Endpoint policy에 특정 버킷만 허용해두고, 새 버킷을 추가하지 않음
aws:SourceVpce조건을 버킷 정책에 걸어두고, 실제 트래픽이 다른 경로로 나감
점검
- VPC Endpoint policy에서
Resource에 해당 버킷 ARN이 포함되는지 - 버킷 정책의 조건 키
aws:SourceVpce값이 실제 Endpoint ID와 일치하는지
7) 프리사인 URL 만료/서명 불일치(시간 오차 포함)
프리사인 URL은 “권한”이 아니라 “서명” 기반이라, 만료되거나 파라미터가 조금만 바뀌어도 실패합니다. 보통은 403 SignatureDoesNotMatch나 AccessDenied로 보입니다.
체크리스트
X-Amz-Expires가 너무 짧지 않은지- 생성한 리전과 실제 버킷 리전이 같은지
- URL을 복사/전달하는 과정에서 쿼리 파라미터가 변형되지 않았는지
- 서버/컨테이너의 시간이 NTP로 동기화되어 있는지
AWS CLI로 프리사인 URL 생성 테스트
aws s3 presign s3://my-bucket/path/to/file.txt --expires-in 600
URL이 즉시 403이면, 권한 문제(생성 주체의 권한, KMS 등)와 서명 문제를 함께 의심하세요.
8) 리전/엔드포인트 혼동으로 잘못된 호스트에 요청
버킷은 특정 리전에 속합니다. 간혹 SDK 설정이나 커스텀 엔드포인트로 인해 다른 리전으로 요청이 나가면, 301 리다이렉트 후에도 서명 리전이 달라져 403이 발생할 수 있습니다.
점검
- 버킷 리전 확인
aws s3api get-bucket-location --bucket my-bucket
- SDK 클라이언트 생성 시 리전이 올바른지
s3.amazonaws.com(글로벌) 대신 리전 엔드포인트를 강제해야 하는지
특히 SigV4 서명은 리전이 서명에 포함되므로, 리전 불일치가 곧 인증 실패로 이어집니다.
9) 오브젝트 소유권(Object Ownership)과 ACL/교차 계정 업로드
교차 계정 업로드에서 403이 난다면, 오브젝트 소유권과 ACL이 얽혀 있을 가능성이 큽니다.
대표 케이스
- A 계정이 버킷 소유자, B 계정이 업로드
- 버킷은 B에게
PutObject는 허용했지만, 업로드된 오브젝트의 소유자가 B로 남아 A가 읽지 못함
해결 방향
- S3 Object Ownership을
Bucket owner enforced로 설정(ACL 비활성화) - 또는 업로드 시
bucket-owner-full-controlACL 사용(ACL을 쓰는 구성이라면)
이 이슈는 “버킷 권한은 있는데 특정 오브젝트만 접근 불가” 형태로 나타나 운영에서 골치 아픕니다.
10) 요청한 키(key)가 다르거나(인코딩/대소문자) HeadObject 권한 누락
S3는 키가 정확히 일치해야 합니다. 아래 같은 실수는 404가 아니라 403으로 보이기도 합니다(권한/정책 구성에 따라 다름).
- URL 인코딩 문제(공백,
+,%2F등) - 대소문자 불일치(키는 대소문자 구분)
- 애플리케이션이 존재 확인을 위해
HeadObject를 호출하는데s3:GetObject만 허용
디버깅 팁: CLI로 같은 키를 그대로 조회
aws s3api head-object --bucket my-bucket --key 'path/ExactKeyName.txt'
여기서도 403이면 권한/정책 문제 가능성이 높고, 404면 키 불일치 가능성이 높습니다.
빠른 실전 체크 순서(추천)
운영에서 시간을 줄이려면 아래 순서가 효율적입니다.
- CloudTrail Data event로
principal과eventName확정 - 같은 principal로 AWS CLI에서 동일 API 재현(
get-object,head-object,list-objects-v2) - IAM 정책에서 Action/Resource 매칭 확인(
ListBucketvsGetObject) - 버킷 정책의
Deny조건 확인(특히aws:SourceVpce,aws:SecureTransport) - Block Public Access 확인
- SSE-KMS면 KMS 키 정책과
kms:Decrypt확인 - VPC Endpoint policy/SCP/Permission boundary 확인
이런 “레이어드 디버깅”은 CI 캐시 디버깅처럼 원인 후보를 체계적으로 줄여나가는 방식이 효과적입니다. 빌드/배포 파이프라인에서의 점검 방식은 GitHub Actions 캐시 미스 - 키·경로 디버깅 실전도 참고할 만합니다.
코드 예제: Node.js에서 403 원인 단서 로그 남기기
애플리케이션에서 403만 찍고 끝내면 원인 추적이 어렵습니다. AWS SDK v3는 메타데이터에 힌트가 들어오는 경우가 많으니, 최소한 아래는 기록하세요.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "ap-northeast-2" });
export async function download(bucket, key) {
try {
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
return res;
} catch (err) {
// err.$metadata.httpStatusCode, err.name 등을 남기면
// AccessDenied, SignatureDoesNotMatch, ExpiredToken 분리가 쉬워집니다.
console.error("S3 error", {
name: err.name,
message: err.message,
httpStatusCode: err.$metadata?.httpStatusCode,
requestId: err.$metadata?.requestId,
extendedRequestId: err.$metadata?.extendedRequestId
});
throw err;
}
}
requestId와 extendedRequestId는 AWS Support나 내부 트러블슈팅에서 결정적인 단서가 됩니다.
마무리: Allow를 늘리기 전에 Deny부터 찾아라
S3 403 AccessDenied는 “권한 부족”이 맞지만, 그 권한은 IAM 하나가 아니라 정책/보안 옵션/네트워크/암호화/조직 정책이 합쳐진 결과입니다. 그래서 문제를 빨리 풀려면
- 어떤 API가 막혔는지
- 누가 호출했는지
- 어디서
Deny가 걸렸는지
를 순서대로 좁혀야 합니다.
운영 장애를 체크리스트로 줄이는 접근은 인프라 전반에 통합니다. 비슷한 형태의 점검 글로는 EKS DiskPressure로 Pod Evicted 폭주 해결 10가지도 함께 보면, 원인 후보를 체계적으로 제거하는 흐름을 잡는 데 도움이 됩니다.