Tutorial

403 Forbidden: A Decision Tree for Permissions, WAF Rules and the Ones That Only Happen on HTTPS

Back to Blog
A modern alternative to cPanel, Plesk and CyberPanel — isolated, secure, AI-assisted.
Start free

A 403 means the server understood you and refused, and there are five different things doing the refusing. File permissions, a directory index rule, an explicit access rule, a web application firewall, or the application itself. They look identical in a browser and need opposite fixes, so the first move is never to change permissions — it is to find out which layer answered. The error log tells you in one line.

Start here: read the error log

Every 403 worth debugging leaves a line. Open the log for that site and reproduce the request:

# nginx
tail -f /var/log/nginx/example.com_error.log

# Apache
tail -f /var/log/apache2/example.com_error.log

# then, in another terminal
curl -sSI https://example.com/path

Match what you see against this:

Log line containsCauseFix direction
Permission deniedFilesystem permissions or ownershipOwnership and mode on the file and every parent directory
directory index of ... is forbiddenNo index file, listing disabledAdd an index file or enable listing deliberately
access forbidden by ruleAn explicit deny in the configurationFind the deny or Require directive
ModSecurity: Access denied with an idWAF ruleTune or disable that specific rule ID
Nothing at allThe application, a CDN, or an upstreamApplication log, then bypass the CDN and retest

The last row is the one that wastes the most time. An empty web server log during a 403 means the web server did not generate it, so no amount of chmod will help.

1. Filesystem permissions

The classic. The web server process cannot read the file, or cannot traverse a directory on the way to it.

# what the server runs as
ps -o user= -C nginx | sort -u

# the full path, not just the file
namei -l /home/user/example.com/public/index.php

namei -l is the command worth remembering here, because it prints permissions for every component of the path. A perfectly readable file inside a directory with mode 700 owned by someone else is still unreachable, and looking only at the file tells you nothing is wrong.

The workable defaults: 755 on directories, 644 on files, owned by the site's user. Do not reach for 777. It fixes the symptom by making the file writable by every process on the machine, which on a shared server includes other customers' PHP.

Also check SELinux if you are on AlmaLinux or Rocky and the permissions genuinely look correct — ls -Z and ausearch -m avc -ts recent. A wrong security context produces a 403 that survives every chmod you throw at it.

2. Directory listing

A request for a directory with no index file returns 403 when listing is disabled — which is the correct default. The log says so explicitly.

Fix by adding the index file, or by enabling listing for that location if exposing the file names is genuinely what you want. Think about that second option before using it: directory listings expose backups, drafts and the .zip somebody left in the web root three years ago.

3. Explicit access rules

Something in the configuration is denying on purpose — an IP allowlist, a protected admin path, a blocked user agent, or a rule inherited from a template.

grep -rn "deny\|allow\|Require" /etc/nginx/sites-enabled/ 2>/dev/null
grep -rn "Require\|Order\|Deny" /path/to/site/.htaccess

The common surprise is the inherited rule. A protected /admin location matching more paths than intended, or a global rule blocking a country, will produce 403s that make no sense while you are staring at the site's own configuration.

4. The WAF, and how to tell immediately

ModSecurity and similar firewalls return 403 for anything a rule dislikes. The signature is unmistakable once you know it: the log line names the rule.

grep ModSecurity /var/log/apache2/example.com_error.log | tail -5
# ModSecurity: Access denied with code 403 ... [id "942100"] [msg "SQL Injection Attack Detected"]

The behavioural signature, if you cannot reach the log: the 403 depends on what you send, not on what you request. The page loads fine normally and 403s when you submit a form containing an apostrophe, an SQL keyword or an HTML tag. That is a WAF, not permissions — permissions do not change their mind based on your POST body.

The fix is to disable the specific rule ID for that location, never to turn the firewall off. False positives cluster in predictable places: page builders, e-commerce checkout, anything posting HTML, and file uploads.

# nginx / ModSecurity, scoped to one location
location /wp-admin/ {
    modsecurity_rules 'SecRuleRemoveById 942100';
}

5. The 403 that only happens over HTTPS

This one is worth its own section because the usual advice never applies.

If http:// works and https:// returns 403, the two requests are not hitting the same configuration. That is the whole diagnosis. Common causes, in order:

Two server blocks, one edited. The port 80 and port 443 blocks are separate, and somebody added the rule, root or index directive to only one of them. Compare them line by line before anything else.

A different document root on the TLS side. Copy-paste history leaves the HTTPS block pointing at a path that does not exist or is owned by someone else. The log will say Permission denied for a path you did not expect to see.

A rule keyed on the scheme. Configuration written as "block unless HTTPS" that ends up blocking on HTTPS instead, usually because $scheme behind a proxy is not what the author assumed.

The WAF is only enabled on the HTTPS vhost. Extremely common, because HTTP is often just a redirect and nobody bothered wiring the firewall into it. The result is a request that passes on port 80 and gets inspected — and rejected — on 443.

The CDN, not your server. If the site is proxied, the 403 may never reach you. Test the origin directly:

curl -sSI https://example.com/path --resolve example.com:443:ORIGIN_IP

If that returns 200 while the public URL returns 403, the refusal is at the edge — a firewall rule, a country block, a bot-fight setting — and nothing on your server will change it.

403 or 401?

They are not interchangeable, and getting it wrong in your own application breaks clients.

401 Unauthorized means authentication is missing or invalid: log in, or send a valid token, and this may succeed. It should carry a WWW-Authenticate header.

403 Forbidden means authentication is not the problem. The server knows who you are, or does not care, and the answer is still no. Retrying with credentials will not help.

The practical consequence: API clients retry a 401 after refreshing a token and give up on a 403. An API that answers 403 for an expired token leaves clients stuck with no path to recovery, and an API that answers 401 for a genuine permission denial sends them into a refresh loop.

The two-minute routine

  1. Reproduce with curl -sSI and confirm it is really a 403.
  2. Watch the site's error log while you reproduce it. The log names the layer.
  3. No log line? Bypass the CDN with --resolve, then check the application's own log.
  4. Log names a ModSecurity rule ID? Tune that ID for that location.
  5. Log says permission denied? Run namei -l on the full path, and check SELinux context on RHEL-family systems.
  6. Works on HTTP but not HTTPS? Diff the two server blocks. The answer is in there.

If the code you are chasing turns out not to be a 403 at all, our reference on what the common status codes mean covers the neighbours, and the odd vendor-specific numbers are in HTTP 430, 440 and 423.

Frequently asked questions

Why do I get 403 Forbidden on HTTPS but not HTTP?

Because the two are served by separate configuration blocks. Usually a rule, document root or index directive exists in only one of them, or a web application firewall is wired into the HTTPS vhost only. Compare the port 80 and port 443 blocks directly.

How do I know if a 403 comes from ModSecurity?

The error log line names the rule, containing ModSecurity: Access denied together with an id. Behaviourally, a WAF 403 depends on what you send rather than what you request — the page loads normally and fails when a form field contains something a rule dislikes.

Does chmod 777 fix a 403?

Sometimes, and it should not be used. It makes the file writable by every process on the machine, including other customers on a shared server. Correct permissions are 755 for directories and 644 for files, owned by the site's user, with every parent directory traversable.

What is the difference between 401 and 403?

401 means authentication is missing or invalid, so retrying with valid credentials may succeed. 403 means credentials are not the issue — the request is refused regardless. Client libraries retry 401 and give up on 403, which is why using the wrong one breaks API consumers.

Why does my server return 403 with nothing in the error log?

Because the web server did not generate it. Either the application returned 403 itself, or a CDN or upstream proxy refused before the request reached you. Test the origin directly with curl --resolve to see which.

Security-first hosting panel

Stop bolting tools onto a legacy panel.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
Built for 2026, not 2002.