S3 presigned URL 만료는 하나가 아닙니다 — 서명한 임시 자격증명이 먼저 끝나면 같이 끝납니다
S3 presigned URL 에 만료 3600초를 넣으면 그 링크는 1시간 동안 살아 있다고 믿었습니다. 그런데 임시 자격증명으로 서명했다면, 자격증명이 먼저 만료되는 순간 URL 도 함께 끝납니다. URL 에 적힌 만료는 약속이 아니라 상한이었습니다.
비공개 파일 다운로드를 설계하며 문서를 다시 읽다가 알게 된 조건입니다. EC2 인스턴스 역할, ECS 태스크 역할, AssumeRole 로 받는 임시 자격증명은 모두 수명이 있어서 생각보다 흔하게 걸립니다. 발급 시점, CORS, 한글 파일명, 없는 파일의 403 도 함께 적습니다.
재현은 @aws-sdk/client-s3·@aws-sdk/s3-request-presigner 3.1086.0 으로 네트워크 호출 없이 서명만 한 것이라, 재현이 보여 주는 범위와 문서에 기대는 범위를 나눠 적습니다. 업로드 쪽은 R2 presigned 멀티파트 업로드 글에 적었습니다.
presigned URL 의 수명을 정하는 것은 둘입니다 — X-Amz-Expires 와 자격증명 만료
SigV4 presigned URL(미리 서명된 URL)은 서명 시각(X-Amz-Date)부터 X-Amz-Expires 초 동안 유효하고, 상한은 7일(604800초)입니다. S3 콘솔에서 만들면 1분~12시간입니다. 이 값이 자격증명의 남은 수명보다 길면 SDK 가 어떻게 하는지 보려고, sessionToken 과 10분 뒤 expiration 을 넣은 가짜 임시 자격증명으로 expiresIn: 3600 을 서명했습니다. expiresIn: 604801 과 forcePathStyle: true 도 함께 시험했습니다.
host: example-private-bucket.s3.ap-northeast-2.amazonaws.com | path: /files/7f3a.pdf
X-Amz-Date = 20260911T140400Z
X-Amz-Expires = 3600
X-Amz-SignedHeaders = host
X-Amz-Security-Token present: true
credential expiration: 2026-09-11T14:14:00.715Z (10 min) vs URL X-Amz-Expires 3600s
expiresIn 604801 -> Error: Signature version 4 presigned URLs must have an expiration date less than one week in the future
forcePathStyle=true host/path: s3.ap-northeast-2.amazonaws.com /example-private-bucket/files/7f3a.pdf
적힌 대로라면 15시 04분(UTC)까지 유효한 URL 인데, 서명한 자격증명은 14시 14분에 끝납니다. 약 50분이 어긋나도 SDK 는 그대로 서명했고, 출력에 경고는 없었습니다. 표준 에러까지 받아 다시 돌려 봐도 경고는 나오지 않았습니다. 604801초는 곧바로 거절했으니, SDK 가 막는 것은 7일 상한이지 자격증명보다 긴 만료가 아니었습니다.
그 뒤는 문서가 말합니다. S3 사용자 가이드 FAQ 에는 «왜 설정한 만료보다 일찍 끝나느냐»는 질문이 따로 있습니다.
A presigned URL expires at either its configured expiration time or when its associated credentials expire, whichever occurs first.
같은 FAQ 는 presigned URL 의 ExpiredToken 오류를 URL 을 만든 자격증명이 더는 유효하지 않다는 뜻으로 설명합니다. 처음엔 «몇 분 뒤 AccessDenied»라고 적으려 했는데, 오류 코드 목록에서 ExpiredToken 은 403 이 아니라 400 이었습니다. 그 응답을 직접 받아 보지는 못했으므로 근거를 나눠 둡니다.
| 근거 | 보여 주는 것 | 보여 주지 않는 것 |
|---|---|---|
| 로컬 재현 | 자격증명보다 긴 만료도 경고 없이 서명됨 | S3 가 그 URL 을 거부하는 시점 |
| S3 사용자 가이드 | 설정한 만료와 자격증명 만료 중 빠른 쪽에 무효 | 실제 응답의 모양 |
| FAQ · 오류 코드 목록 | ExpiredToken 은 자격증명 무효, 상태 코드 400 |
모든 만료가 이 코드로 오는지 여부 |
임시 자격증명의 남은 수명 — 그래서 누르는 순간에 발급합니다
FAQ 가 적은 수명은 이렇습니다. EC2 인스턴스 프로파일 자격증명은 주기적으로 교체되며 최대 약 6시간, ECS 태스크 역할 자격증명은 보통 1~6시간마다 교체되고, AssumeRole 세션은 기본 1시간입니다. 놓치기 쉬운 것은 남은 수명이 서명하는 순간마다 다르다는 점입니다.
AssumeRole 기본값이면 세션을 받은 직후가 아닌 한 남은 시간은 1시간보다 짧으니, 1시간짜리 URL 은 적힌 만큼 살지 못합니다. URL 에는 서명 당시의 세션 토큰(X-Amz-Security-Token)이 들어 있고 그 자격증명을 따라 만료되므로, 서버가 새 자격증명을 받아도 이미 나간 URL 은 늘어나지 않습니다.
그래서 발급 시점을 다시 생각하게 됐습니다. 목록을 그릴 때 URL 을 미리 만들면 누르기 전부터 URL 이 늙습니다. 누르는 시점에 발급하고 만료는 수 분으로 두는 편이 맞다고 봅니다. 5분짜리 URL 이 자격증명에 먼저 걸리려면 그 5분 안에 자격증명이 끝나야 하니, 어긋날 수 있는 창도 좁아집니다.
큰 파일이라 걱정된다면, S3 는 만료를 HTTP 요청 시점에 검사합니다. 만료 직전에 시작한 다운로드는 도중에 시각이 지나도 계속되고, 끊긴 뒤 만료 후에 다시 시작하면 실패합니다. 응답에 만료 시각을 준다면 서명 직전 시각으로 계산하되, 그 값도 적힌 만료일 뿐입니다.
presigned URL 링크로 내려받는 데는 버킷 CORS 가 필요 없습니다
링크 클릭이나 window.location 같은 top-level 이동은 Fetch 표준의 navigate 모드라 CORS 검사를 거치지 않습니다. 서명된 헤더도 host 하나뿐이니 브라우저가 따로 붙일 헤더가 없습니다. CORS 는 방식을 바꿀 때 필요해집니다.
| 방식 | 버킷 CORS | 저장 여부를 정하는 것 |
|---|---|---|
링크 클릭 · window.location |
필요 없음 | 응답의 Content-Disposition |
fetch() · XHR 로 받아 Blob 저장 |
필요 | 페이지의 코드 |
교차 출처 <a download> |
필요 없음 | download 속성은 무시, 응답의 Content-Disposition |
오해하기 쉬운 것은 세 번째 줄입니다. 다른 출처를 가리키면 download 속성은 무시되고, 저장 여부는 응답 헤더가 정합니다. RFC 6266 은 attachment 를 미디어 타입대로 처리하지 말고 사용자에게 저장을 묻게 하라는 뜻으로 정의합니다.
S3 에서는 GetObject 의 response-content-disposition 파라미터로 이 헤더를 덮어씁니다. 이 파라미터는 서명된 요청에서만 쓸 수 있어 presigned URL 과 짝이 맞고, 덮어쓴 헤더는 200 OK 응답에만 붙습니다. 만료나 없는 키로 실패하면 사용자는 파일 대신 오류 응답을 받게 됩니다.
Content-Disposition 한글 파일명 — encodeURIComponent 가 남기는 네 글자
키는 files/7f3a.pdf 처럼 의미 없는 값으로 두고, 사람이 보는 이름은 filename* 에만 싣습니다. 한글이면 먼저 encodeURIComponent 에 손이 갑니다. 견적서 (최종).pdf 로 비교했습니다.
encodeURIComponent : %EA%B2%AC%EC%A0%81%EC%84%9C%20(%EC%B5%9C%EC%A2%85).pdf
rfc8187 attr-char : %EA%B2%AC%EC%A0%81%EC%84%9C%20%28%EC%B5%9C%EC%A2%85%29.pdf
첫 줄은 encodeURIComponent 만 쓴 결과라 괄호가 남았습니다. 이 함수는 영문자·숫자와 - _ . ! ~ * ' ( ) 를 이스케이프하지 않는데, RFC 8187 §3.2.1 의 attr-char 는 영문자·숫자와 ! # $ & + - . ^ _ ` | ~ 만 허용하고 나머지는 퍼센트 인코딩하도록 정합니다. 겹쳐 보면 ' ( ) * 네 글자가 attr-char 밖에 남습니다.
작은따옴표는 ext-value(charset "'" [ language ] "'" value-chars)에서 구분자이기도 합니다. UTF-8'' 이 문자셋 UTF-8 에 언어를 비운 모양입니다. 두 번째 줄은 네 글자를 추가로 바꾼 결과이고, MDN 의 Content-Disposition 예제 함수도 같은 네 글자를 바꿉니다.
브라우저가 이 정도 어긋남을 너그럽게 받아 주는지는 확인하지 않았습니다. 재현은 헤더 값을 만들었을 뿐 저장된 파일 이름을 보지 않았습니다. 다만 맞추는 비용이 정규식 한 줄이라 맞추는 쪽을 택했습니다. 구형 구현을 위해 filename 을 폴백으로 함께 보낸다면, RFC 6266 은 그것을 앞에 두고 ASCII 가 아닌 문자는 넣지 말라고 조언합니다.
합치면 발급 함수는 이런 모양입니다. 재현에 쓴 호출과 인코딩을 옮겨 정리했고, 이 형태 그대로 실행해 보지는 않았습니다.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'ap-northeast-2' });
const EXPIRES_IN = 300; // 5분, 누를 때마다 발급
const rfc8187 = (s: string) =>
encodeURIComponent(s).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
export async function issueDownloadUrl(key: string, filename: string) {
const expiresAt = new Date(Date.now() + EXPIRES_IN * 1000); // 서명 직전 기준
const url = await getSignedUrl(s3, new GetObjectCommand({
Bucket: 'example-private-bucket', Key: key,
ResponseContentDisposition: `attachment; filename*=UTF-8''${rfc8187(filename)}`,
}), { expiresIn: EXPIRES_IN });
return { url, expiresAt }; // 적힌 만료일 뿐, 자격증명이 먼저 끝나면 더 이르게 무효
}
S3 에 없는 파일인데 403 이 옵니다 — ListBucket 이 가르는 404 와 403
없는 키면 404 일 것 같지만, GetObject 문서의 답은 요청 주체에게 s3:ListBucket 권한이 있으면 404(NoSuchKey), 없으면 403 입니다. HeadObject 도 같고, HEAD 는 본문이 없어 403 상태만 보입니다. presigned URL 은 만든 주체의 자격증명을 쓰므로, 서명한 역할에 ListBucket 이 없으면 없는 파일 링크도 403 을 받습니다.
위험한 것은 판단 로직입니다. 403 을 «없음»으로 간주하는 삭제·정리 로직은 권한 문제를 존재하지 않음으로 오판하고, 권한 구성이 한 번 어긋나면 있는 것을 없는 것으로 처리합니다. 거꾸로 «403 이면 권한 없음»도 반만 맞습니다. 둘을 가르려면 응답이 아니라 서명한 주체의 권한 구성을 봐야 합니다.
요즘 S3 버킷과 SDK 의 기본값 — SSE-C 쓰기 차단까지
SDK v3 S3Client 의 forcePathStyle 기본값은 false 라, 앞 출력 첫 줄처럼 버킷이 호스트에 들어가는 virtual-hosted-style 이 기본이고 true 면 마지막 줄처럼 경로로 내려갑니다. path-style 중단 계획은 2020년 9월 23일 연기됐고, 2026년 9월 11일 현재 모든 리전에서 지원되며 새 중단 날짜는 없습니다.
- 2023년 4월부터 새 버킷은 Block Public Access 가 켜지고 ACL 이 꺼진(BucketOwnerEnforced) 상태라, 객체를 공개로 돌려 내려주는 길은 기본으로 닫혀 있습니다.
- 2023년 1월 5일부터 새 객체에는 SSE-S3 가 기본 적용됩니다.
- 2026년 4월부터 새 범용 버킷은 SSE-C 쓰기가 기본 차단입니다(중동 바레인·UAE 제외, SSE-C 객체가 없던 계정은 기존 버킷도). 차단된 SSE-C 쓰기는 403 AccessDenied 로 거부되고, 필요하면 PutBucketEncryption 의
BlockedEncryptionTypes를NONE으로 둡니다. IAM 권한이 아니라 버킷 설정이 막는 403 입니다.
남는 것 — 적힌 만료는 상한이었습니다
가장 오래 남은 것은 URL 에 적힌 숫자와 실제 수명이 다른 곳에서 정해진다는 점입니다. X-Amz-Expires 는 제가 고른 값이지만, 지켜질지는 서명한 자격증명이 정합니다. 상한을 약속으로 읽으면 짧은 쪽을 놓칩니다.
두 번째는 근거를 가르는 일입니다. 재현이 보여 준 것은 SDK 가 어긋난 두 수명을 경고 없이 서명한다는 데까지이고, 10분 뒤 거부된다는 것과 그 오류 이름은 문서에서 읽었을 뿐입니다. 한 문장으로 뭉치면 매끄럽겠지만, 나중의 제가 어디까지 확인했는지 알 수 없게 된다는 생각이 들었습니다.
세 번째는 403 입니다. 없는 키도, 차단된 암호화 방식도 403 이었습니다. 상태 코드는 결론보다 단서에 가까웠습니다. 다음에 presigned URL 을 만들 때는 만료 값을 정하기 전에, 서명할 자격증명이 얼마나 남았는지부터 묻게 될 것 같습니다.
참고 문서
관련 글
Cloudflare R2 presigned 멀티파트 업로드 — mock 160개가 못 잡은 버그 2개
바이트가 서버를 통과하지 않는 순간, 서버는 파일 크기도 업로드 완료 여부도 스토리지에 남은 것도 잃습니다. R2 presigned 멀티파트를 붙이며 그 셋을 되찾은 과정과, mock 160개가 초록불인 채로 실물 레인 하나가 잡아낸 버그 두 개를 기록했습니다.
EC2를 띄우기 전에 AWS CLI로 먼저 잰 것들 — 계획서가 틀려 있던 8가지
Coolify를 EC2에 올리기 전, 읽기 전용 describe만으로 계정을 먼저 쟀습니다. 계획서의 1순위 단계는 불필요했고, 정작 필수였던 것들은 계획서에 아예 없었습니다. 비용 0인 준비만 먼저 끝낸 과정을 정리했습니다.
와일드카드 인증서는 HTTP-01으로 못 받습니다 — DNS-01이 강제하는 IAM 결정
멀티테넌트 서브도메인을 위해 와일드카드 인증서를 발급하려다 알게 된 것은, 문제가 TLS가 아니라 DNS 쓰기 권한이라는 점이었습니다. 프록시에 Route53 권한을 주는 순간 생기는 폭발 반경과 그것을 좁히는 방법을 정리했습니다.