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 을 만들 때는 만료 값을 정하기 전에, 서명할 자격증명이 얼마나 남았는지부터 묻게 될 것 같습니다.

참고 문서

Amazon S3presigned URL임시 자격증명CORSContent-Disposition한글 파일명파일 다운로드