Next.js 16 App Router 위에서, CDN URLPrefix 서명 쿠키로 사용자별 트리에 인증을 걸고, V4 signed POST policy로 브라우저가 오브젝트 스토리지에 직접 업로드하며, imgproxy를 같은 prefix 아래 라우팅해 단일 쿠키로 원본/변환 이미지를 모두 인증한다.
비즈니스 도메인은 비어 있다. 어떤 사용자가 어떤 콘텐츠를 다루는지는 위에서 얹는 문제이고, 이 저장소는 그 밑판 — 권한 경계, 인증된 미디어 전송, 직접 업로드의 검증 — 에만 집중한다.
브라우저 → Next 미들웨어
├─ 세션에서 userId 확인
├─ 요청 호스트가 CDN 쿠키 도메인 안인지 검사 (eTLD+1 가드)
├─ 기존 Cloud-CDN-Cookie 파싱
│ ├─ 만료까지 buffer 이상 남았는가?
│ ├─ URLPrefix가 현재 사용자에 맞는 스코프인가?
│ ├─ KeyName이 현재 키와 일치하는가?
│ └─ 현재 키로 서명을 재계산했을 때 일치하는가? ← 위조 쿠키 방어 핵심
└─ 4개 모두 만족 → 그대로 두기
→ 아니면 직접 조립한 Set-Cookie로 새 쿠키 발급
쿠키 한 개로 /<ns>/<userId>/file/... (스토리지 origin)과
/<ns>/<userId>/img/... (imgproxy origin)을 모두 인증한다.
<Image src="/u/<userId>/file/.../p.jpg" />에 커스텀 loader가 붙어, 브라우저는
/u/<userId>/img/unsafe/preset:w_<W>:q_<Q>/<base64(gs://...)>.<ext> 형태의
URL을 그린다. 같은 prefix이므로 쿠키가 그대로 따라가고, imgproxy는
presets-only + UNSAFE 모드로 동작한다 — 서명 키를 클라이언트에 내릴 수
없는 상황에서, 변환 화이트리스트(presets)와 prefix 인증의 결합이 권한 경계가
된다.
[1] /api/files/policy 인증된 세션 + Zod 검증 + 정책 매트릭스 검증
→ V4 signed POST policy 발급
→ destination key는 서버가 결정 (클라이언트 입력 없음)
→ content-length-range, eq $Content-Type을 정책에 못 박음
[2] POST policy.url 브라우저 → GCS 직접 업로드
→ 정책의 max보다 큰 파일은 GCS가 거절
[3] /api/files/complete objectKey가 users/<sessionUserId>/로 시작하는지 가드
→ 스토리지에서 size/md5/generation을 읽어 DB에 박음
→ 클라이언트가 신고한 값은 신뢰하지 않음
GCS Pub/Sub 같은 비동기 이벤트로 대체할 수도 있지만, 동기 3단계가 reasoning이 단순하고 실패 추적이 쉬워 이쪽을 선택했다.
쿠키를 들고 다닐 수 없는 워커/외부 시스템용으로, 같은 알고리즘으로 URLPrefix 서명 URL을 발급한다. 만료 디폴트 15분, 상한 7일 (CDN 스펙).
| 결정 | 이유 | 반례 |
|---|---|---|
| URLPrefix 서명 쿠키 | srcset/lazy-load와 호환. 한 번 발급, prefix 아래 자유 접근 | 매 URL 서명: srcset에서 폭발, lazy-load와 충돌 |
| HMAC-SHA1 | GCP Cloud CDN 스펙이 강제 | 다른 CDN(CloudFront RSA-SHA1, Akamai 토큰 등)이면 모듈 교체 필요 |
| Web Crypto | edge / node / browser / worker 모두 동작 | Node crypto: edge runtime에서 실패 |
직접 조립한 Set-Cookie |
프레임워크 helper가 값을 URL 인코딩 → CDN이 signed_request_invalid_format로 거절 |
NextResponse.cookies.set: 위 이유로 실패 |
| 갱신 시 서명 재검증 | 위조된 쿠키(서명 무효, 만료 미래)가 영구히 살아남는 것 차단 | 만료만 검사: 위조 쿠키 영구화 |
| URLPrefix에서 포트 제거 | CDN이 서명 검증 시 포트 무시. 클라이언트가 :443을 명시해도 일치해야 함 | 포트 포함: 비표준 포트 접근 시 invalid_format |
| imgproxy presets-only + UNSAFE | 키를 브라우저에 내릴 수 없음. 변환 화이트리스트화로 보안 경계를 presets 카탈로그에 둠 | 매번 서명 URL: 서버 왕복 + lazy-load 충돌 |
| imgproxy를 같은 prefix 아래 라우팅 | 단일 쿠키로 두 origin 인증 | 별도 도메인: 두 번째 인증 체계 필요 |
| imgproxy → GCS 직접 fetch | CDN 경유 시 자기 자신 fetch 순환 + 이중 egress | CDN 경유: 비용/순환 |
| V4 POST policy | content-length-range를 정책에 박음. 정책 leak 시에도 무한 업로드 불가 |
PUT signed URL: 사이즈 제한 강제 불가 |
| 정책 매트릭스 | 새 종류 추가 = 한 줄. 검증/키빌드/정책 발급이 자동 일관 | 호출부 분기: 곧 누락됨 |
| 동기 complete 단계 | reasoning 단순, 실패 추적 쉬움 | Pub/Sub 비동기: 종속성/지연/eventual consistency |
users/<id>/... 스토리지 키, <ns>/<id>/file/... CDN 경로 |
CDN 경로는 srcset에 박혀 페이지 무게에 영향 → 짧을수록 좋음 | 동일 경로: srcset 무게 손실 |
| 날짜 + 타임스탬프 + UUID 키 | 날짜 폴더로 listing/lifecycle, 타임스탬프로 정렬, UUID로 충돌 방지 | 단일 식별자 only: 운영 도구 부족 |
userId는 세션에서만 |
요청 바디에서 받으면 다른 사용자 namespace 침투 가능 | 바디 입력: cross-user 침투 |
| 1시간 TTL + 10분 buffer | 헤더 잡음 최소 + 시계 drift 마진 | 짧은 TTL: drift에 취약 / 매 요청 발급 |
.
├── app/
│ ├── (demo)/demo/page.tsx 데모 페이지
│ ├── api/auth/switch/route.ts 데모용 세션 전환
│ ├── api/files/policy/route.ts POST policy 발급
│ ├── api/files/complete/route.ts 업로드 등록 + 메타 정규화
│ ├── api/files/list/route.ts 내 파일 목록만
│ └── api/files/m2m-url/route.ts CDN signed URL
├── components/ 데모 UI
├── hooks/useDirectUpload.ts 브라우저 직접 업로드 3단계
├── lib/
│ ├── auth/session.ts 세션 stub
│ ├── cdn/crypto.ts Web Crypto base64url + HMAC-SHA1
│ ├── cdn/signedCookie.ts 쿠키 발급 + 갱신 로직
│ ├── cdn/signedUrl.ts M2M용 서명 URL
│ ├── image/imgproxyLoader.ts Next/Image 커스텀 loader
│ ├── storage/client.ts GCS 싱글톤
│ ├── storage/postPolicy.ts V4 POST policy
│ ├── storage/metadata.ts 객체 메타데이터 + signed read URL
│ ├── storage/pathMap.ts storage key ↔ CDN path 양방향 매핑
│ ├── upload/policies.ts kind 매트릭스
│ ├── upload/keyBuilder.ts sanitize + destination key
│ ├── upload/validation.ts 요청 검증
│ └── db.ts SQLite 단일 테이블
├── middleware.ts 쿠키 발급 진입점
├── db/schema.sql
└── .env.example
각 파일 상단 주석에 왜 그 모양인지에 대한 근거를 적어두었다.
흔히 저지르는 실수의 정확한 형태를 인라인 주석에도 같이 남겼다.
response.cookies.set()로 CDN 서명 쿠키를 만들지 않는다 (값이 URL 인코딩되어 CDN이 거절).- 쿠키 갱신 스킵을 만료시간만 보고 결정하지 않는다 (서명 재계산 필수).
- URLPrefix 서명 입력에 포트를 포함하지 않는다.
- V4 POST policy에서
content-length-range를 빠뜨리지 않는다. - imgproxy 서명 키를 클라이언트로 내려보내지 않는다.
- imgproxy URL이 Next의 image optimizer를 다시 거치지 않게 한다.
- 업로드 destination key를 요청 바디 값으로 만들지 않는다.
- 클라이언트가 보낸 size/md5/contentType을 DB에 그대로 저장하지 않는다.
- 쿠키 도메인을 leading-dot 없이 설정하지 않는다 (cross-subdomain silent drop).
- 쿠키 prefix 스코프를 CDN 전체 origin으로 두지 않는다.
- 서명 키를 cookie / URL / imgproxy 간에 공유하지 않는다.
- 정책 발급 엔드포인트를 인증 없이 두지 않는다.
설계가 의도대로 동작하는지 손으로 확인할 수 있는 13가지 시나리오 중 의미 있는 것들:
- 위조 쿠키 거절 — DevTools에서
Signature=부분을 임의로 바꿔 요청하면, 다음 응답에 새Set-Cookie가 강제로 발급된다 (서명 검증 실패가 갱신 트리거). - 포트 정규화 —
:443을 명시해서 접근해도 서명이 일치한다. - destination 무결성 — 요청 바디에 다른 사용자
userId나objectKey를 심어 보내도, 서버가 결정하는 destination에 영향을 주지 않는다. content-length-range동작 — 정책의 max를 우회한 업로드 시도는 GCS가 직접 거절한다.- complete의 namespace 가드 — 다른 사용자 트리의
objectKey로/complete을 호출하면 403. - imgproxy 변환 + 재포장 방지 — 썸네일이
unsafe/preset:w_*:q_*/...로 가져와지고, imgproxy URL을 다시<Image>에 넣어도 nested wrapping이 발생하지 않는다.
단위 테스트(vitest)는 base64url round-trip, RFC 2202 HMAC-SHA1 test vector, URLPrefix 포트 정규화의 서명 불변성, sanitiser 엣지 케이스, storage key ↔ CDN path 매핑까지를 커버한다.
cp .env.example .env.local # CDN_*, STORAGE_*, NEXT_PUBLIC_* 채우기
npm install
npm run dev # https://localhost:3000/demo
npm run test # vitest
npm run typecheck # tsc --noEmitSecure 쿠키와 cross-subdomain 흐름을 그대로 검증하려면 로컬에서도 HTTPS가
필요하다. next dev --experimental-https 또는 mkcert로 셋업하고,
/etc/hosts에 media.example.test 같은 항목을 추가한다. imgproxy는 docker
한 줄로 띄울 수 있고, presets 이름은 loader와 정확히 일치해야 한다.
- CDN 패밀리: GCP Cloud CDN의 서명 알고리즘에 맞춰져 있다. CloudFront
(RSA-SHA1)나 Akamai 토큰 등 다른 패밀리로 갈 경우
lib/cdn/{signedCookie, signedUrl}.ts두 파일을 교체하면 된다 — 호출부는 그대로다. - DB: 데모는 SQLite. Postgres로 바꾸려면
lib/db.ts만 교체. - 세션: stub이다. NextAuth/Clerk 등으로 교체할 때
getSession()의 반환 시그니처({ userId } | null)만 맞추면 나머지 코드는 그대로다. - 콘텐츠 진위 검증: POST policy의
eq $Content-Type은 클라이언트 신고만 강제한다. 이미지의 magic byte까지 검증하려면 complete 단계에 후크 자리를 마련해두었다 (TODO 주석).