저는 내부 자료를 Supabase Storage에 보관하다가 “혹시 평문으로 남아 있지는 않을까?” 하는 걱정에 잠이 오질 않았습니다. 권한 관리가 탄탄해도 스토리지 키가 유출되면 그대로 열람될 수 있으니까요. 그래서 서버에서 파일을 업로드하는 순간 AWS KMS와 AES-GCM으로 이중 보호하는 파이프라인을 직접 구축했습니다.
업로드 요청이 들어오면 먼저 KMS에서 AES-256 데이터 키를 발급받습니다. 평문 키는 즉시 웹 크립토 API로 파일 암호화에 사용하고, 암호화된 키(Base64)는 키 테이블에 보관합니다.
import { generateDataKey } from './kms';
import { aesGcmEncrypt } from './crypto';
interface EncryptedPayload {
encrypted: ArrayBuffer;
iv: Uint8Array;
encryptedKey: string;
}
export async function encryptFileBeforeUpload(file: File): Promise<EncryptedPayload> {
// 1. KMS에서 파일 전용 데이터 키를 발급받습니다.
const { plaintextKey, encryptedKey } = await generateDataKey('file');
// 2. 업로드할 파일을 ArrayBuffer로 읽습니다.
const fileBuffer = await file.arrayBuffer();
// 3. AES-GCM으로 암호화하고 IV를 함께 반환합니다.
const { encrypted, iv } = await aesGcmEncrypt(
fileBuffer,
validateAndConvertKey(plaintextKey),
);
return { encrypted, iv, encryptedKey };
}
암호문은 Blob으로 감싸 서명된 URL에 PUT 요청으로 업로드합니다. 성공하면 키 테이블에 경로, 암호화된 키, IV를 저장합니다. 저장 중 문제가 생기면 이미 업로드한 객체를 제거해 키와 파일이 분리되지 않도록 했습니다.
다운로드 요청이 올 때는 암호화된 키를 KMS에서 다시 복호화하고, 저장된 IV를 이용해 AES-GCM으로 원본 파일을 복원합니다. 복원된 ArrayBuffer는 MIME 타입을 판별한 뒤 인라인 미리보기 또는 첨부 다운로드 형태로 응답했습니다.
업로드 API는 허용된 버킷만 화이트리스트로 통과시키고, 인증되지 않은 요청은 즉시 차단합니다. 이렇게 하면 스토리지 보안 규칙을 과도하게 복잡하게 만들지 않아도 됩니다.
generateDataKey가 Plaintext와 Ciphertext를 모두 돌려주니 별도 캐시는 쓰지 않았지만, 재시도 간 1초씩 지연을 둬 AWS 제한을 피했습니다.arrayBufferToBase64로 안전하게 변환하고, 다운로드 시 정확히 역변환해 씁니다.generateSecureFileName에서 타임스탬프와 UUID를 섞은 이름을 만들어 버렸습니다.이제 Storage 브라우저로 들어가도 암호문만 보여서 마음이 한결 편해졌습니다. KMS 키를 잃어버리지 않는 이상 평문 유출 가능성이 사라졌고, Supabase RLS도 간결하게 유지할 수 있었습니다. 다음에는 비동기로 돌아가는 deleteEncryptedFile 경로에 감사 로그를 붙여서 누가 언제 삭제했는지도 추적해볼 생각입니다.
여러분은 서버 사이드 암호화를 어떻게 구현하고 계신가요? 비슷한 고민이 있다면 댓글로 경험을 공유해 주세요. 저는 특히 KMS 호출 비용을 더 줄이는 방법이 궁금합니다.