What 403 means

403 Forbidden is the server saying “I understood the request, and I’m not going to do it.” Unlike a 404, the problem isn’t that nothing is there. Unlike 401, it isn’t asking you to log in. Something along the way looked at the request and decided no.

That “something” is the key question, because in a modern setup there are several candidates: a CDN or WAF in front, the web server, file system permissions, the application’s own access checks, and sometimes an object store like S3 behind it all. Each produces a 403 for different reasons.

One wrinkle: servers may also return 404 instead of 403 to avoid confirming that a protected resource exists, and some (S3, for example) return 403 for things that simply don’t exist. So the code alone isn’t proof of either.

If you’re a visitor

Most 403s that hit ordinary visitors are about the connection, not the person:

  • A VPN, proxy or cloud IP. Many sites block datacenter ranges because that’s where bots live. Turn off the VPN and retry.
  • Your country. Some sites block traffic from outside their market, and some block specific regions.
  • Browser extensions that change headers, block scripts the site’s bot check needs, or spoof the User-Agent.
  • Too many requests, for example many tabs reloading at once. Waiting a few minutes often clears it.

Your IP check shows how your address looks from outside, including whether it belongs to a hosting or cloud network. If the block page has a reference number (Cloudflare shows a Ray ID, Akamai a “Reference #”), send it to the site owner; it identifies the exact rule that blocked you.

Step 1: find out which layer said no

Read the response before touching any config:

curl -sI https://www.example.com/admin/
curl -s https://www.example.com/admin/ | head -30
What you seeWho refused
“403 Forbidden” with “nginx” underneathnginx: permissions, missing index, or a deny rule
“Forbidden. You don’t have permission to access this resource.”Apache (older versions phrase it slightly differently)
“Sorry, you have been blocked” or “Error 1020 Access denied”, with a Ray IDCloudflare security rules, before the request reached the origin
“403 ERROR. The request could not be satisfied.”Amazon CloudFront (often AWS WAF, a geo restriction, or the S3 origin refusing)
“Access Denied … Reference #”Akamai
XML with <Code>AccessDenied</Code>Amazon S3
“HTTP Error 403.14 - Forbidden”IIS: no default document and directory browsing off
Your own site’s designThe application’s authorization or CSRF check

The headers help too: cf-ray means Cloudflare handled it, x-amz-cf-id means CloudFront, a server header shows the web server. If the 403 page came from the CDN, nothing in your web server config will fix it, and the web server’s access log won’t even contain the request.

Cloudflare uses a family of 1xxx codes on its block pages, shown with status 403: 1020 when a firewall or WAF rule blocks the request, 1009 when the owner has blocked the visitor’s country, 1010 when the browser’s signature is blocked, and 1006 to 1008 for banned IPs. All of them are settings in the site owner’s Cloudflare account.

Compare variations of the request to see what the rule keys on:

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

If the browser User-Agent changes the result, it’s bot protection. If the -e Referer changes it, it’s hotlink protection. The HTTP check runs from cloud infrastructure rather than your network, so a 403 there and a 200 in your browser points at an IP, datacenter or country rule.

File system permissions and ownership

The most common origin-side cause. The web server process (www-data on Debian/Ubuntu, nginx or apache on RHEL-family) needs read permission on the file and execute permission on every directory above it. Missing x on a single parent directory is enough.

nginx logs it clearly:

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

namei shows the permissions of every component of the path at once, which is faster than checking each directory:

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

A common trap is serving from a home directory. Newer Ubuntu releases create home directories as 750, so the web server can’t traverse /home/alice at all. Move the site to /var/www or /srv rather than opening up the home directory.

The usual fix:

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

On RHEL, Rocky, Alma and Fedora, correct Unix permissions can still give 403 if the files have the wrong SELinux context. This happens almost every time files are moved (not copied) from a home directory, because mv keeps the old label. Check and fix:

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

For a document root outside the standard paths, register it with semanage fcontext -a -t httpd_sys_content_t "/srv/site(/.*)?" and then run restorecon. Don’t disable SELinux to make a 403 go away.

A directory with no index file

You request / or /docs/, the directory exists, there is no index.html (or whatever the index setting lists), and directory listing is off. The server refuses rather than showing the folder’s contents.

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)

Typical reasons: the build didn’t produce index.html, the deploy went to the wrong directory, or it’s a PHP site and index.php isn’t in nginx’s index directive or Apache’s DirectoryIndex.

Fix the index file or the setting. Don’t turn on autoindex or Options +Indexes to make the error disappear: an open listing exposes backups, config files and everything else in the folder. The exposed admin page check flags open directory listings on your subdomains if you want to confirm none slipped through.

Explicit deny rules

Something in the config says no on purpose, and it’s catching a request it shouldn’t.

On Apache 2.4 the main config denies everything by default (<Directory /> with Require all denied), and each site must grant its own document root. A new document root without a matching block produces:

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

Add a <Directory /srv/site> block with Require all granted. Mixing old 2.2-style Order/Deny from lines with 2.4 Require lines also produces confusing results; convert them.

On nginx, look for deny and return 403 in the effective config:

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

A rule worth checking: the common snippet that blocks dotfiles, location ~ /\. { deny all; }, also blocks /.well-known/, which breaks Let’s Encrypt HTTP validation and other well-known URLs. Exclude it:

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

IP allowlists behind a proxy or CDN

An admin area restricted with allow 198.51.100.7; deny all; suddenly blocks you after you put the site behind Cloudflare or a load balancer. The server now sees the proxy’s address, not yours. Configure the real-IP module so the allowlist sees the visitor:

set_real_ip_from 173.245.48.0/20;   # repeat for each Cloudflare range
real_ip_header CF-Connecting-IP;

Use the ranges the CDN publishes (Cloudflare lists them at https://www.cloudflare.com/ips-v4 and /ips-v6), and only trust the header from those ranges, otherwise anyone can claim any IP.

If the document root or a file is a symlink and Apache isn’t allowed to follow it, you get AH00037: Symbolic link not allowed or link target not accessible. Enable Options FollowSymLinks (or SymLinksIfOwnerMatch) for that directory, and make sure the target is readable by the web server too.

WAF false positives

A WAF (ModSecurity with the OWASP Core Rule Set, Cloudflare’s managed rules, AWS WAF) blocks requests that look like attacks. Ordinary traffic sometimes does: a blog post containing SQL, a form field with <script> in a code sample, a large upload, an unusual header from an app.

The tell is that the 403 depends on what you send, not on who you are or which page you’re on: the same form works with different text.

Find the rule that fired. On Cloudflare it’s Security → Events, searchable by Ray ID. On ModSecurity, the audit log (its location is set by SecAuditLog) and the error log line ModSecurity: Access denied with code 403 include the rule ID. Then write a narrow exception for that rule on that path or parameter. Turning the whole WAF off for a form is the wrong trade.

Bot protection blocks requests that don’t look like a browser: curl’s default User-Agent, scripts, monitoring services, sometimes legitimate API clients. If your own integrations are getting 403s, create an explicit exception for them (by path, token or IP) rather than weakening the protection for everyone.

Geo and IP blocks are often set once and forgotten. A site that blocks traffic from outside one country also blocks its own staff travelling abroad, many uptime monitors and some search engine crawlers that come from other countries. If you rely on a geo block, test from the outside and check that the crawlers you care about can still reach the pages that should be indexed.

Hotlink protection returns 403 for images or files requested with a Referer from another site. In nginx:

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

If your own images break, the usual cause is a domain you forgot to list: the bare domain, a CDN hostname, or a new subdomain. Keep none in the list so direct visits and privacy-conscious browsers that send no Referer aren’t blocked.

Object storage: a 403 that is really a 404

S3 returns AccessDenied (403) for a missing object if the caller isn’t allowed to list the bucket, so a typo in a key looks like a permissions problem. Behind CloudFront this is common for two cases: a deep link in a single-page app (the object /dashboard doesn’t exist), and a request for / when no default root object is set. Set the default root object, and for SPAs map 403 and 404 to /index.html in the distribution’s custom error responses.

The application said no

If the 403 page is your own, the application decided. Two common cases:

  • CSRF protection on form submissions. Django shows “CSRF verification failed. Request aborted.” and Spring Security returns a 403. Usually the form is missing the token, the session cookie isn’t being sent (cookie domain, SameSite, or HTTPS-only cookies on an HTTP page), or a cached page carries an old token.
  • Authorization: the user is logged in but lacks the role. This is the one case where logging in as someone else changes the answer.

Paths you should forbid on purpose

Some paths should return 403 (or 404) to everyone: .git/, .env, backup files like *.sql or *.bak, and admin panels that don’t need to be public. Check from outside:

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

Anything that returns 200 is a bigger problem than any 403 on this page. Better still, keep those files out of the document root entirely. The exposed admin page check looks for public admin pages, login screens and directory listings across your subdomains; checking for sensitive files requires verifying that you own the domain.

After the fix

Re-run the request that failed, then the HTTP check from outside. If you changed a CDN or WAF rule, test from a network other than your office, since many rules depend on the source IP. And confirm the paths that should stay forbidden still are.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.