403이 뜻하는 것

403 Forbidden은 서버가 “요청은 알아들었고, 해 주지 않겠다”고 말하는 것입니다. 404와 달리 아무것도 없다는 게 문제가 아니고, 401과 달리 로그인을 요구하는 것도 아닙니다. 중간의 무언가가 요청을 보고 거부하기로 한 것입니다.

핵심 질문은 그 ‘무언가’가 무엇이냐입니다. 요즘 구성에는 후보가 여럿 있습니다. 앞단의 CDN이나 WAF, 웹 서버, 파일 시스템 권한, 애플리케이션 자체의 접근 검사, 때로는 그 뒤의 S3 같은 오브젝트 스토리지까지. 각각 다른 이유로 403을 냅니다.

한 가지 주의할 점이 있습니다. 서버는 보호된 리소스가 있다는 사실을 확인시켜 주지 않으려고 403 대신 404를 돌려주기도 하고, S3처럼 그냥 없는 것에 403을 돌려주는 곳도 있습니다. 그래서 코드만으로는 어느 쪽인지 단정할 수 없습니다.

방문자라면

일반 방문자가 만나는 403은 대부분 사람이 아니라 접속 경로에 관한 것입니다.

  • VPN, 프록시, 클라우드 IP. 봇이 거기서 오기 때문에 데이터센터 대역을 막는 사이트가 많습니다. VPN을 끄고 다시 시도하세요.
  • 접속 국가. 자기 시장 밖의 트래픽을 막거나 특정 지역을 막는 사이트가 있습니다. 국내 공공기관 사이트 중에는 해외 IP를 막는 곳이 있어서, 해외에서 접속하면 403이나 시간 초과가 날 수 있습니다.
  • 브라우저 확장 프로그램. 헤더를 바꾸거나, 사이트의 봇 검사에 필요한 스크립트를 막거나, User-Agent를 위장하는 확장이 원인일 수 있습니다.
  • 너무 많은 요청. 탭 여러 개를 한꺼번에 새로고침한 경우 등입니다. 몇 분 기다리면 풀리는 경우가 많습니다.

IP 확인은 바깥에서 내 주소가 어떻게 보이는지, 호스팅이나 클라우드 네트워크 소속인지까지 보여 줍니다. 차단 화면에 참조 번호가 있으면(Cloudflare는 Ray ID, Akamai는 “Reference #”) 사이트 운영자에게 전달하세요. 어떤 규칙이 막았는지 정확히 특정할 수 있습니다.

1단계: 어느 층이 거부했는지 찾기

설정을 건드리기 전에 응답부터 읽으세요.

curl -sI https://www.example.com/admin/
curl -s https://www.example.com/admin/ | head -30
보이는 것거부한 주체
“403 Forbidden” 아래에 “nginx”nginx: 권한, index 누락, deny 규칙
“Forbidden. You don’t have permission to access this resource.”Apache(구버전은 문구가 조금 다름)
Ray ID와 함께 “Sorry, you have been blocked” 또는 “Error 1020 Access denied”요청이 원 서버에 닿기 전 Cloudflare 보안 규칙
“403 ERROR. The request could not be satisfied.”Amazon CloudFront(AWS WAF, 국가 제한, S3 원본의 거부인 경우가 많음)
“Access Denied … Reference #”Akamai
<Code>AccessDenied</Code>가 든 XMLAmazon S3
“HTTP Error 403.14 - Forbidden”IIS: 기본 문서가 없고 디렉터리 검색이 꺼져 있음
내 사이트 디자인의 화면애플리케이션의 권한 검사나 CSRF 검사

헤더도 도움이 됩니다. cf-ray가 있으면 Cloudflare가, x-amz-cf-id가 있으면 CloudFront가 처리한 것이고, server 헤더는 웹 서버를 알려 줍니다. 403 화면을 CDN이 만들었다면 웹 서버 설정을 아무리 고쳐도 소용없고, 웹 서버의 접근 로그에는 그 요청이 아예 없습니다.

Cloudflare는 차단 화면에 1xxx 계열 코드를 쓰고, 상태 코드는 403입니다. 방화벽·WAF 규칙이 막으면 1020, 운영자가 방문자의 국가를 막았으면 1009, 브라우저 시그니처가 막혔으면 1010, IP 차단은 1006~1008입니다. 모두 사이트 운영자의 Cloudflare 계정 설정입니다.

요청을 조금씩 바꿔 가며 규칙이 무엇을 보는지 확인하세요.

curl -s -o /dev/null -w '%{http_code}\n' https://www.example.com/
curl -s -o /dev/null -w '%{http_code}\n' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' https://www.example.com/
curl -s -o /dev/null -w '%{http_code}\n' -e 'https://www.example.com/' https://www.example.com/img/a.jpg

브라우저 User-Agent를 넣으니 결과가 바뀐다면 봇 차단입니다. -e로 Referer를 넣으니 바뀐다면 핫링크 방지입니다. HTTP 점검은 내 네트워크가 아니라 클라우드 인프라에서 요청하므로, 거기서는 403인데 내 브라우저에서는 200이라면 IP·데이터센터·국가 기준 규칙을 의심하세요.

파일 시스템 권한과 소유권

원 서버 쪽에서 가장 흔한 원인입니다. 웹 서버 프로세스(Debian·Ubuntu는 www-data, RHEL 계열은 nginx나 apache)는 파일에 읽기 권한이, 그 위의 모든 디렉터리에 실행(x) 권한이 있어야 합니다. 상위 디렉터리 하나에 x가 빠져도 막힙니다.

nginx는 로그에 분명하게 남깁니다.

open() "/var/www/site/index.html" failed (13: Permission denied)

namei를 쓰면 경로의 모든 단계 권한을 한 번에 볼 수 있어 디렉터리를 하나씩 확인하는 것보다 빠릅니다.

namei -l /var/www/site/index.html

홈 디렉터리에서 서비스하는 경우가 흔한 함정입니다. 최근 Ubuntu 릴리스는 홈 디렉터리를 750으로 만들기 때문에 웹 서버가 /home/alice를 아예 지나가지 못합니다. 홈 디렉터리를 열지 말고 사이트를 /var/www나 /srv로 옮기세요.

일반적인 해결은 이렇습니다.

sudo chown -R deploy:www-data /var/www/site
sudo find /var/www/site -type d -exec chmod 755 {} +
sudo find /var/www/site -type f -exec chmod 644 {} +

SELinux

RHEL, Rocky, Alma, Fedora에서는 Unix 권한이 맞아도 파일의 SELinux 컨텍스트가 틀리면 403이 납니다. 홈 디렉터리에서 파일을 복사가 아니라 이동(mv)했을 때 거의 매번 생깁니다. mv는 예전 레이블을 그대로 유지하기 때문입니다. 이렇게 확인하고 고칩니다.

ls -Z /var/www/site
sudo restorecon -Rv /var/www/site
sudo ausearch -m avc -ts recent

표준 경로가 아닌 곳을 document root로 쓴다면 semanage fcontext -a -t httpd_sys_content_t "/srv/site(/.*)?"로 등록한 뒤 restorecon을 실행하세요. 403을 없애려고 SELinux를 끄지는 마세요.

index 파일이 없는 디렉터리

/나 /docs/를 요청했는데 디렉터리는 있고, index.html(또는 index 설정에 적힌 파일)이 없고, 디렉터리 목록 표시가 꺼져 있습니다. 서버는 폴더 내용을 보여 주는 대신 거부합니다.

directory index of "/var/www/site/" is forbidden          (nginx)
AH01276: Cannot serve directory /var/www/html/: No matching DirectoryIndex (index.html) found, and server-generated directory index forbidden by Options directive          (Apache)

흔한 이유는 빌드가 index.html을 만들지 못했거나, 배포가 엉뚱한 디렉터리로 갔거나, PHP 사이트인데 nginx의 index 지시어나 Apache의 DirectoryIndex에 index.php가 없는 경우입니다.

index 파일이나 설정을 고치세요. 오류를 없애려고 autoindex나 Options +Indexes를 켜지는 마세요. 열린 목록은 백업, 설정 파일 등 폴더에 든 모든 것을 드러냅니다. 혹시 빠뜨린 곳이 없는지 확인하고 싶다면 관리자 페이지 노출 점검이 서브도메인 전체에서 열린 디렉터리 목록을 찾아 줍니다.

명시적인 deny 규칙

설정의 무언가가 일부러 거부하고 있는데, 막지 말아야 할 요청까지 잡고 있는 경우입니다.

Apache 2.4는 메인 설정에서 기본적으로 모든 것을 거부하고(<Directory />에 Require all denied), 사이트마다 자기 document root를 허용해야 합니다. 새 document root에 맞는 블록이 없으면 이렇게 나옵니다.

AH01630: client denied by server configuration: /srv/site/

Require all granted를 넣은 <Directory /srv/site> 블록을 추가하세요. 2.2 방식의 Order·Deny from 줄과 2.4 방식의 Require 줄이 섞여 있어도 헷갈리는 결과가 나오니 하나로 바꾸세요.

nginx에서는 실제 적용된 설정에서 deny와 return 403을 찾으세요.

nginx -T | grep -n -E 'deny|return 403|allow'

꼭 확인할 규칙이 하나 있습니다. 점(.)으로 시작하는 파일을 막는 흔한 설정 location ~ /\. { deny all; }은 /.well-known/까지 막아서 Let’s Encrypt의 HTTP 검증과 다른 well-known URL을 깨뜨립니다. 예외를 두세요.

location ~ /\.(?!well-known) { deny all; }

프록시·CDN 뒤의 IP 허용 목록

allow 198.51.100.7; deny all;로 묶어 둔 관리자 영역이, 사이트를 Cloudflare나 로드밸런서 뒤로 옮긴 뒤 갑자기 나를 막습니다. 서버가 이제 내 주소가 아니라 프록시 주소를 보기 때문입니다. real IP 모듈을 설정해 허용 목록이 방문자의 주소를 보게 하세요.

set_real_ip_from 173.245.48.0/20;   # Cloudflare 대역마다 반복
real_ip_header CF-Connecting-IP;

CDN이 공개한 대역(Cloudflare는 https://www.cloudflare.com/ips-v4 와 /ips-v6)을 쓰고, 그 대역에서 온 헤더만 믿으세요. 그렇지 않으면 누구나 아무 IP나 주장할 수 있습니다.

심볼릭 링크

document root나 파일이 심볼릭 링크인데 Apache가 따라가도록 허용되지 않았다면 AH00037: Symbolic link not allowed or link target not accessible이 납니다. 그 디렉터리에 Options FollowSymLinks(또는 SymLinksIfOwnerMatch)를 켜고, 링크 대상도 웹 서버가 읽을 수 있는지 확인하세요.

WAF 오탐

WAF(OWASP Core Rule Set을 쓰는 ModSecurity, Cloudflare 관리형 규칙, AWS WAF)는 공격처럼 보이는 요청을 막습니다. 평범한 트래픽도 가끔 그렇게 보입니다. SQL이 들어간 블로그 글, 코드 예시에 <script>가 든 폼 필드, 큰 업로드, 앱이 보내는 특이한 헤더 같은 것들입니다.

구별 방법: 403이 누가 보냈는지나 어느 페이지인지가 아니라 무엇을 보냈는지에 따라 달라집니다. 같은 폼이 다른 내용으로는 통과합니다.

걸린 규칙을 찾으세요. Cloudflare는 존의 Security > Analytics → Events 탭에서 Ray ID로 검색할 수 있습니다. ModSecurity는 감사 로그(위치는 SecAuditLog 설정)와 오류 로그의 ModSecurity: Access denied with code 403 줄에 규칙 ID가 나옵니다. 그다음 그 경로나 파라미터에 한해 그 규칙만 예외로 두세요. 폼 하나 때문에 WAF 전체를 끄는 건 잘못된 거래입니다.

봇 차단, 국가 차단, 핫링크 방지

봇 차단은 브라우저처럼 보이지 않는 요청을 막습니다. curl의 기본 User-Agent, 스크립트, 모니터링 서비스, 때로는 정상적인 API 클라이언트도 걸립니다. 내 연동 서비스가 403을 받는다면 보호 수준을 모두에게 낮추지 말고, 그 연동만 경로·토큰·IP로 명시적인 예외를 두세요.

국가·IP 차단은 한 번 걸어 두고 잊기 쉽습니다. 한 나라 밖의 트래픽을 막는 사이트는 해외 출장 중인 직원, 많은 가용성 모니터링 서비스, 다른 나라에서 오는 일부 검색엔진 크롤러까지 함께 막습니다. 국가 차단을 쓴다면 바깥에서 테스트하고, 색인돼야 할 페이지에 원하는 크롤러가 여전히 닿는지 확인하세요.

핫링크 방지는 다른 사이트의 Referer로 요청된 이미지나 파일에 403을 돌려줍니다. nginx에서는 이렇습니다.

valid_referers none blocked server_names *.example.com;
if ($invalid_referer) { return 403; }

내 사이트의 이미지가 깨진다면 목록에서 빠뜨린 도메인 때문인 경우가 대부분입니다. 루트 도메인, CDN 호스트명, 새로 만든 서브도메인 같은 것들입니다. 직접 방문이나 Referer를 보내지 않는 브라우저가 막히지 않도록 none은 목록에 남겨 두세요.

오브젝트 스토리지: 사실은 404인 403

S3는 호출자에게 버킷 목록 조회 권한이 없으면 없는 객체에 AccessDenied(403)를 돌려줍니다. 그래서 키 이름의 오타가 권한 문제처럼 보입니다. CloudFront 뒤에서는 두 경우에 흔합니다. SPA의 딥 링크(/dashboard라는 객체는 없음)와, 기본 루트 객체를 설정하지 않은 상태에서의 / 요청입니다. 기본 루트 객체를 설정하고, SPA라면 배포의 사용자 지정 오류 응답에서 403과 404를 /index.html로 연결하세요.

애플리케이션이 거부한 경우

403 화면이 내 사이트 디자인이라면 애플리케이션이 결정한 것입니다. 흔한 경우는 두 가지입니다.

  • 폼 제출의 CSRF 보호. Django는 “CSRF verification failed. Request aborted.”를 보여 주고, Spring Security는 403을 돌려줍니다. 대개 폼에 토큰이 없거나, 세션 쿠키가 전송되지 않거나(쿠키 도메인, SameSite, HTTP 페이지에서의 HTTPS 전용 쿠키), 캐시된 페이지가 옛 토큰을 들고 있는 경우입니다.
  • 권한 부족: 로그인은 했지만 필요한 역할이 없습니다. 다른 계정으로 로그인하면 결과가 바뀌는 유일한 경우입니다.

일부러 막아야 할 경로

모든 사람에게 403(또는 404)을 돌려줘야 하는 경로가 있습니다. .git/, .env, *.sql·*.bak 같은 백업 파일, 공개할 필요가 없는 관리자 화면입니다. 바깥에서 확인해 보세요.

for p in .git/HEAD .env backup.sql wp-config.php.bak; do
  printf '%-22s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://www.example.com/$p)"
done

200이 나오는 것이 있다면 이 페이지의 어떤 403보다 큰 문제입니다. 더 좋은 건 그런 파일을 아예 document root 밖에 두는 것입니다. 관리자 페이지 노출 점검은 서브도메인 전체에서 공개된 관리자 화면, 로그인 화면, 디렉터리 목록을 찾아 줍니다. 민감한 파일 점검은 도메인 소유를 인증한 뒤에 할 수 있습니다.

고친 다음에

실패했던 요청을 다시 보내고, 바깥에서 HTTP 점검을 돌리세요. CDN이나 WAF 규칙을 바꿨다면 사무실이 아닌 다른 네트워크에서도 테스트하세요. 출발지 IP에 따라 달라지는 규칙이 많습니다. 그리고 막혀 있어야 할 경로가 여전히 막혀 있는지도 확인하세요.

내 도메인에서 바로 확인

무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.