대상: 공유/임대 호스팅이나 VPS에서 여러 사이트를 운영하며 공개 디렉터리(public/, public_html/, htdocs/ 등)로 배포하는 모든 사람. 공격 절차는 없습니다 — 오직 자기 서버를 점검해 문제를 막는 것뿐입니다. .env 자체를 보호하는 법은 공유 호스팅에서 .env 보호하기를 보세요.
본 사이트의 견해: 어디에 두느냐가 곧 접근 제어다
시크릿은 교묘한 공격보다 배치 사고로 더 많이 샙니다. 웹루트는 "누구나 URL로 와서 가져갈 수 있는 곳"입니다. 거기에 시크릿을 두면 아무리 강한 서버 비밀번호도 무의미합니다. 본 사이트의 기본 원칙은 시크릿을 웹루트와 git 양쪽에서 빼두는 것입니다(→ 시크릿을 안전하게 보관하기). 발상은 단순합니다: 공개 디렉터리에는 누가 봐도 괜찮은 것만 두세요.
public/ = 누구나 URL로 가져갈 수 있음
두어도 OK
이미지, CSS, JS — 공개 에셋
절대 두지 말 것
.env, token.json, .sql, .git, .bak
앱 루트 위 = 서빙 안 됨
여기에 둘 것
시크릿, 토큰, 자격증명, 백업. 권한 600(소유자 읽기만). URL로 닿을 수 없음.
"공개 디렉터리의 잔여물"이 생기는 이유
흔한 패턴은 편의상 둔 파일, 혹은 보일러플레이트 안에 딸려 온 파일이 조용히 공개된 채 남는 것입니다.
예를 들어 제3자 연동용 액세스 토큰을 담은 JSON이 public/ 아래에 오래 놓여 있고 — 공유 템플릿에서 왔다면 같은 파일이 많은 사이트의 같은 자리에 복사되어 모두 같은 문제를 갖습니다. 한 사이트만 고쳐도 다른 사이트에 파일이 남아 있으면 무의미합니다. 똑같이 위험한 것: 남겨진 .env, 데이터베이스 백업(.sql), .git 디렉터리, 에디터 백업(*.bak), 단축 파일(관련: 경로 순회 · .env 전체가 노출됨).
남겨지기 쉬운 파일과 각각이 위험한 이유
"시크릿 파일" 하면 보통 .env를 떠올리지만, 실제로 남겨지는 것은 훨씬 다양합니다. 웹 보안을 다루는 비영리 단체 OWASP의 테스트 가이드도 파일의 이전 버전, 백업, 어디서도 링크되지 않은 파일을 점검 대상으로 꼽습니다.
| 종류 | 대표적인 이름 | 위험한 것 | 있어야 할 곳 |
|---|---|---|---|
| 설정 파일 | .env, config.php.bak, settings.json | 평문으로 적힌 데이터베이스 비밀번호와 API 키 | 앱 루트 위(서빙 안 됨) |
| 토큰과 자격증명 | token.json, credentials.json, *.pem, *.key | 외부 서비스나 다른 서버에 바로 로그인 | 웹루트 밖, 권한 600 |
| 수동 또는 에디터 백업 | login.php.old, index.php~, .index.php.swp, *.orig | 확장자가 바뀌어 코드로 실행되지 않고, 소스가 평문으로 보일 수 있음 | 운영 환경에서 만들지 않는다. 로컬에서 편집 |
| 데이터베이스 덤프 | backup.sql, dump.sql.gz | 사용자의 개인정보와 비밀번호 해시가 파일 하나에 전부 | 전용 백업 장소, 가능하면 서버 밖 |
| 사이트 압축 파일 | site.zip, www.tar.gz | .env와 전체 소스 코드를 포함 | 압축을 푼 직후 삭제 |
| 버전 관리 데이터 | .git/ | 과거 커밋의 시크릿을 복원할 수 있음 | 공개 디렉터리 밖 |
| 빌드 시 삽입된 값 | assets/index-xxxx.js 내부 | VITE_나 NEXT_PUBLIC_으로 시작하는 변수가 브라우저용 JavaScript에 들어감 | 시크릿은 서버 측 변수에만 둔다 |
마지막 행은 놓치기 쉽습니다. 파일을 직접 두지 않았더라도 빌드가 시크릿을 공개 디렉터리로 밀어 넣을 수 있습니다. Vite 공식 문서는 VITE_ 변수에 API 키 같은 민감한 정보를 넣으면 안 된다고 분명히 적고 있습니다.
자기 서버를 점검하라
먼저 "내 공개 디렉터리에 시크릿 같은 파일이 있는가"를 기계적으로 드러내세요. 이는 자기 자산을 점검하는 것이지 남의 사이트를 엿보는 것이 아닙니다.
공개 디렉터리 아래에서 시크릿 같은 파일을 검색한다
public/에 흘러든 시크릿 후보(토큰/자격증명 JSON, .env, 키, 단축 파일)를 드러내세요.다른 곳에도 같은 파일이 있다고 가정하고 — 모든 호스트와 사이트를 확인한다
파일마다 정말 공개가 필요한지 판단한다
# audit under your own public directories (inspecting your own assets)
find ~ -path '*/public/*' \( -iname '*token*.json' -o -iname '*credential*.json' \
-o -iname '*.bak' -o -iname '*.sql' -o -iname '.env*' \) 2>/dev/null
# an exposed .git directory is dangerous too
find ~ -path '*/public/*/.git' -maxdepth 8 2>/dev/null내 환경을 점검하는 법
위의 find는 출발점입니다. 아래 다섯 가지 점검을 차례로 하면 파일 이름 검색만으로는 놓치는 잔여물까지 잡을 수 있습니다. 반드시 자기 서버와 자기 도메인에만 실행하세요. .env에 특화된 점검(응답의 첫 바이트 확인, .htaccess가 실제로 작동하는지 확인)은 공유 호스팅에서 .env 보호하기에서 다룹니다.
백업과 압축 파일도 검색한다
공개 디렉터리 이름은 호스팅마다 다르므로 public_html, htdocs, www도 포함하세요. 예를 들어 find ~/public_html \( -name '*~' -o -name '*.swp' -o -name '*.old' -o -name '*.orig' -o -name '*.zip' -o -name '*.tar.gz' -o -name '*.pem' -o -name '*.key' \) 2>/dev/null을 실행하고, 결과를 하나씩 정말 공개해도 되는 것인지 확인하세요.
디렉터리 목록 표시가 꺼져 있는지 확인한다
index.html이 없는 디렉터리(예: /uploads/)를 브라우저로 열어 보세요. 파일 이름 목록이 보이면 웹 서버의 디렉터리 목록 기능이 켜져 있는 것입니다. 목록이 있으면 이름을 몰라도 잔여물을 찾아낼 수 있습니다. 403이나 404가 나와야 정상입니다.
빌드된 JavaScript에서 시크릿을 검색한다
프런트엔드를 빌드한다면, 배포하는 결과물 폴더(dist/, .next/static/ 등)를 검색하세요. grep -rlE 'BEGIN [A-Z ]*PRIVATE KEY|secret|password' dist/ 같은 명령에, 쓰는 서비스의 키 접두어도 더해 찾습니다. 발견되면 그 변수가 브라우저용 이름(VITE_, NEXT_PUBLIC_)인지 확인하세요.
웹루트 밖의 시크릿을 다른 사용자가 읽을 수 없는지 확인한다
시크릿을 웹루트 밖으로 옮겨도, 권한이 느슨해 같은 서버의 다른 계정이 읽을 수 있다면 소용없습니다. find ~ -maxdepth 3 \( -name '.env*' -o -name '*.pem' -o -name '*token*.json' \) -perm /o+r 2>/dev/null은 소유자 외의 누구나 읽을 수 있는 시크릿을 나열합니다. chmod 600으로 고치세요. 아무것도 나오지 않으면 괜찮습니다.
검색 엔진에 색인된 것이 있는지 확인한다
Google에서 site:yourdomain(yourdomain은 내 도메인)으로 검색해 모르는 파일(.sql, .zip, .txt 등)이 없는지 보세요. Search Console을 쓴다면 페이지 보고서에 색인된 URL이 나열됩니다. 검색 결과에 아직 남아 있을 때의 대처는 아래 흔한 실수에서 다룹니다.
찾았을 때 할 일
세 가지를 한 묶음으로 하세요 — 제거, 폐기, 이동. 하나라도 빠뜨리면 대응이 완전하지 않습니다.
URL로 가져갈 수 없도록 웹루트에서 제거한다
샜을 수 있는 키나 토큰을 폐기/재발급한다
시크릿은 웹루트 밖에 권한 600으로 둔다
서버별: 이런 파일의 서빙을 거부하는 설정(보조 수단으로)
진짜 해결책은 파일을 거기 두지 않는 것입니다. 그 위에, 실수로 둔 것도 서빙되지 않도록 웹 서버에 거부 규칙을 더하세요. 쓸 수 있는 방법은 환경에 따라 다릅니다.
| 환경 | 거부 방법 | 디렉터리 목록 | 주의할 점 |
|---|---|---|---|
| Apache(VPS) | <Directory> 블록이나 .htaccess에서 <FilesMatch> + Require all denied | Options -Indexes로 끈다 | Apache 2.4에서 AllowOverride의 기본값은 None이므로, 허용하지 않으면 .htaccess는 무시된다 |
| Nginx(VPS) | location 안에 deny all; | autoindex는 기본값이 off | .htaccess를 읽지 않는다. 정규식 location 블록은 적힌 순서대로 확인되고 처음 일치한 것이 적용된다 |
| 공유 호스팅 | 보통 .htaccess(Apache 호환) | 관리 화면이나 .htaccess로 | 시크릿이 없는 더미 파일로 작동을 확인한다 |
| 정적 호스팅 / CDN | 결과물 폴더에 있는 것이 그대로 공개된다 | 서비스에 따라 다름 | 거부 규칙이 아니라 결과물 폴더에 시크릿을 넣지 않는 것에 의존한다 |
Apache 예시(.htaccess가 활성화된 경우):
Options -Indexes
<FilesMatch "(^\.|~$|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key)$)">
Require all denied
</FilesMatch>Nginx 예시(server 블록 안):
# Allow the path used for automatic certificate renewal (Let's Encrypt) first
location ^~ /.well-known/acme-challenge/ { }
# Deny files and directories starting with a dot (.env, .git, ...)
location ~ /\. { deny all; }
# Deny backups and archives
location ~* (~|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key))$ { deny all; }Nginx에서는 요청이 ^~가 붙은 location과 일치하면 정규식 location은 확인하지 않습니다. 그래서 점으로 시작하는 경로를 거부하는 규칙이 있어도 인증서 갱신에 쓰이는 /.well-known/acme-challenge/는 계속 서빙됩니다. 설정을 바꾼 뒤에는 nginx -t로 문법을 확인하고, 다시 불러온 다음, 더미 파일이 403을 반환하는지 확인하세요. 사이트에서 ZIP 파일 등을 다운로드로 제공한다면 그 확장자는 거부 목록에서 빼세요.
사람들이 빠지는 배치(위험)
- 연동용 JSON 토큰이
public/에 놓임 - 보일러플레이트에 딸려 온 시크릿 파일이 공개된 채 남음
- 백업(
.sql/.bak)이나.git이 공개 디렉터리 아래에 남음 - 한 사이트만 고치고 "처리 완료"라고 함
올바른 배치
- 공개 디렉터리엔 공개해도 되는 것만
- 시크릿은 웹루트 밖에 권한 600으로
- 민감한 확장자는 서빙이 거부됨
- 하나 찾으면 모든 호스트와 사이트를 점검
'공개해도 안전한 것만'을 상시 규칙으로
규칙이 일회성 수정을 이깁니다. 공개 디렉터리에는 공개해도 되는 파일만 둔다고 정하고, 규칙상 시크릿·백업·버전 관리 데이터(.git)·설정 파일을 절대 거기 두지 마세요. 보일러플레이트를 갱신할 때는 템플릿 자체에서 시크릿을 제거하세요 — 그렇지 않으면 앞으로 대량 생산하는 모든 사이트가 같은 시크릿 파일을 계속 복사합니다.
흔한 실수와 고치는 법
- 거부 규칙에 의존하고 파일은 그대로 둔다: 서버 이전이나 설정 재작성으로 규칙이 빠지면 그날부터 파일이 공개됩니다. Nginx는
.htaccess를 전혀 읽지 않습니다. 규칙은 보조 수단으로 보고, 파일을 공개 디렉터리 밖으로 옮기세요. - 운영 서버에서 파일을 직접 편집한다: 에디터가
~나.swp로 끝나는 백업을 남깁니다. OWASP 테스트 가이드도 웹 서버에서 파일을 직접 편집하지 말라고 권합니다. 로컬에서 편집하고 배포를 통해 반영하세요. - 프로젝트 폴더 전체를 공개 디렉터리에 복사한다:
.env,.git, 테스트 데이터가 함께 나갑니다. 빌드 결과물 폴더만 서빙하고, 복사할 대상을 정확히 지정하세요. - 점으로 시작하는 경로를 모두 거부해 인증서 갱신이 깨진다: Let's Encrypt는 확인용 파일을
/.well-known/acme-challenge/아래에 둡니다. 위의 Nginx 예시처럼 그 경로를 먼저 허용하세요. VITE_나NEXT_PUBLIC_변수에 시크릿을 넣는다: 빌드된 JavaScript에 삽입되어 누구나 읽을 수 있습니다. 서버 측 코드에서만 쓰고, 이미 이렇게 배포한 키는 폐기하세요.- 파일을 삭제하고 거기서 멈춘다: 검색 엔진이 색인했다면 결과에 남아 있을 수 있습니다. URL이 404나 410을 반환하는지 확인한 뒤, 재크롤링으로 사라질 때까지 Google Search Console의 삭제 도구로 일시적으로(약 6개월) 숨기세요.
실제 사례: 공개 보고서에 숨어 있던 개인정보
워싱턴 D.C.에서 Medicaid(저소득층을 위한 공적 의료보험)를 운영하는 보건의료재정국(DHCF)은, 2023년부터 2026년 7월까지 웹사이트에 게시한 통계 보고서 두 건에 원본 개인정보가 "숨겨진 필드"로 들어 있었다고 밝혔습니다. 보고서는 집계 수치만 보이도록 만들어졌지만, 파일에는 수급자의 Medicaid 번호, 생년월일 등이 남아 있었습니다. 미국 보건복지부(HHS)에 제출한 신고에서 피해자 수는 399,086명이었습니다. DHCF는 2026년 7월 21일 이 문제를 알게 되어 보고서를 내렸습니다.
잊힌 시크릿 파일 사례는 아니지만, 뿌리는 같습니다.
- 공개 디렉터리의 파일은 화면에 보이는 부분만이 아니라 통째로 가져갈 수 있다
- 게시자가 "보이지 않는다"고 생각하는 정보가 검토에서 가장 놓치기 쉽다
- 누군가 알아채기까지 몇 년 동안 게시된 채로 남을 수 있다
게시 전에 "이 파일 안에 실제로 무엇이 들어 있는가"를 확인하는 단계가 잔여 파일과 숨은 데이터를 모두 막습니다. 전체 경위는 워싱턴 D.C. Medicaid 데이터 노출(영문)에 있습니다.
본 사이트는 직접 어떻게 하는가
본 사이트의 기본 원칙은 시크릿 — 키, 토큰, 접속 문자열 — 을 공개 디렉터리와 코드 저장소 양쪽에서 빼두는 것입니다. 배포 산출물에는 빌드된 공개 에셋만 들어가고, 시크릿은 서빙되지 않는 곳에 런타임 환경 변수로 보관합니다. 이유는: 이 글의 사고 부류가, 남겨진 파일 하나가 그 URL을 요청한 누구에게나 전부를 유출하는 전형적인 사례이기 때문입니다. 우리는 배치 자체를 접근 제어로 설계하고, 이 점검을 취약점 대응 인벤토리와 같은 주기로 돌립니다.
다음으로 읽기
- 스택: 공유 호스팅에서 .env 보호하기 · 취약점 대응 플레이북(인벤토리)
- 용어집: .env 파일과 시크릿 · 경로 순회
- 사고: .env 전체가 노출됨
- 보관: 시크릿을 안전하게 보관하기
- 클라우드에서: 공개 버킷(영문)(서버 밖에서 일어나는 같은 문제)
- 업체가 침해되었을 때: 웹 호스팅 업체가 침해되었을 때 할 일(영문)(거기 있던 것은 읽혔다고 가정)
- 실제 사례: 공개 보고서에 남은 Medicaid 번호(워싱턴 D.C., 영문)(공개 파일이 통째로 노출됨)
출처
- IPA, 「안전한 웹사이트 운영 관리를 위한 20가지 점검 포인트」(일본어; 공개하면 안 되는 파일, 검색 엔진 캐시): ipa.go.jp
- OWASP, "Web Security Testing Guide: Review Old Backup and Unreferenced Files for Sensitive Information"(.old, ~, .swp 백업; 서버에서 직접 편집하지 말 것): wstg.owasp.org
- Apache HTTP Server 2.4, "Upgrading to 2.4 from 2.2"(
AllowOverride의 기본값은None) 및 "mod_autoindex"(Options +Indexes가 목록을 생성): httpd.apache.org, httpd.apache.org - nginx, "ngx_http_core_module: location"(일치 순서와
^~) 및 "ngx_http_autoindex_module"(autoindex의 기본값은off): nginx.org, nginx.org - Let's Encrypt, "Challenge Types"(HTTP-01은
/.well-known/acme-challenge/를 사용): letsencrypt.org - Vite, "Env Variables and Modes"(
VITE_변수는 클라이언트 코드에 노출됨): vite.dev - Next.js, "Environment Variables"(
NEXT_PUBLIC_변수는 브라우저용 JavaScript에 삽입됨): nextjs.org - Google Search Console 고객센터, "Removals tool"(일시 삭제는 약 6개월 유지): support.google.com
- District of Columbia DHCF, "Notice of Data Incident": dhcf.dc.gov; HIPAA Journal(HHS에 보고된 인원): hipaajournal.com