# HTTP 403 Forbidden: Access Denied

> 403 Forbidden means the server refuses the request. Find whether Cloudflare, nginx, Apache, S3 or your app sent it; fix permissions, WAF rules and role checks.

Source: https://howhttpworks.com/status-codes/403
Last reviewed: 2026-10-05

> **TL;DR:** 403 Forbidden means the server refuses to let you access this URL. Find whether the response came from your app, web server or WAF, then check the matching role, scope, filesystem permission or blocking rule.

RFC 9110 section 15.5.4: the server "understood the request but refuses to fulfill it". Unlike [401](https://howhttpworks.com/status-codes/401), re-authenticating normally does not help, though a different account might. A 403 can come from your application, the web server, a WAF, or the CDN, and the fix differs for each, so identify the sender first. The longer walkthrough is in the [403 Forbidden debug guide](https://howhttpworks.com/debug/403-forbidden).

The messages and response bodies here are illustrative. Client messages use `https://api.example.com/resource`; reason phrases and URLs can vary.

```http
HTTP/1.1 403 Forbidden
Server: cloudflare
CF-Ray: 8f1c2a9b7e0d1234-AMS
Content-Type: text/html

<title>Attention Required! | Cloudflare</title>
Sorry, you have been blocked
```

## What you see in your client

- **Axios:** `AxiosError: Request failed with status code 403` with the default status handling. Inspect `error.response.data` for the server's explanation. [Axios source](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js).
- **fetch:** the promise resolves with `response.status === 403` and `response.ok === false`. Check the status before reading the body; HTTP errors do not enter `catch` automatically. [MDN fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch).
- **Python requests:** `requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://api.example.com/resource` when you call `response.raise_for_status()`. [Requests source](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status).
- **curl -f:** `curl: (22) The requested URL returned error: 403`. Use `curl -i` without `-f` while inspecting the error body. [curl source](https://github.com/curl/curl/blob/master/lib/http.c).
- **.NET:** `HttpRequestException` with the English message `Response status code does not indicate success: 403 (Forbidden).` after `EnsureSuccessStatusCode()`. [Runtime message template](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx).
- **Spring WebClient:** `WebClientResponseException$Forbidden: 403 Forbidden from POST https://api.example.com/resource` with `retrieve()`. Read `getResponseBodyAsString()`. [Spring 6.2 source](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java).
- **Spring RestTemplate (6.2):** `HttpClientErrorException$Forbidden: 403 Forbidden on POST request for "https://api.example.com/resource": [no body]` with the default error handler and an empty response body. Read the exception body when present. [Message builder](https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java), [exception subclasses](https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/web/client/HttpClientErrorException.html).

## Who sent it?

| Evidence | Layer |
| --- | --- |
| `Server: cloudflare`, `CF-Ray`, page titled "Attention Required! \| Cloudflare" with a Ray ID | Cloudflare firewall, WAF managed rule, Bot Fight Mode or IP Access Rule. Look up the Ray ID under Security > Events |
| `Server: nginx`, body `403 Forbidden`; error log `directory index of "/var/www/html/" is forbidden` or `open() "/var/www/x" failed (13: Permission denied)` | nginx filesystem or `deny` rule |
| `Server: Apache`, log `AH01630: client denied by server configuration` or `AH00035: access to / denied ... because search permissions are missing on a directory` | Apache `Require`/permissions |
| `Request blocked. We can't connect to the server for this app or website at this time` with `X-Cache: Error from cloudfront` | CloudFront with WAF or geo restriction |
| XML `<Error><Code>AccessDenied</Code>` | S3 bucket policy, Block Public Access, or missing `s3:GetObject` |
| `ModSecurity` / `Mod_Security` in body, or `This request has been blocked by the website's security rules` | ModSecurity or a host WAF |
| JSON `{"error":"Forbidden"}` | Your application's authorization check |

## Fix it, most common first

1. **Missing index file or directory listing off.** A request for `/` with no `index.html` and `autoindex off` gives 403. Add the file or an `index` directive.
2. **Filesystem permissions.** The web server user (`www-data`, `nginx`, `apache`) needs `x` on every parent directory and `r` on the file. Check with `namei -l /var/www/site/index.html`. SELinux: `ls -Z` and `restorecon -Rv /var/www`.
3. **A WAF or CDN rule.** Find the event for your request (Cloudflare Security Events by Ray ID, AWS WAF sampled requests, ModSecurity audit log) and tune or exclude that rule rather than disabling the WAF.
4. **IP, Referer, User-Agent or country block.** For a configured User-Agent rule, compare with `-A 'Mozilla/5.0'`. Hotlink protection returns 403 when `Referer` is foreign.
5. **Authorization logic.** The user is authenticated but lacks the role, scope, or ownership. Return the missing scope in the body, and use `WWW-Authenticate: Bearer error="insufficient_scope"` for OAuth APIs.
6. **CSRF or same-origin checks.** Django's `CSRF verification failed. Request aborted.` returns 403; Rails maps `InvalidAuthenticityToken` to 422. Pass the token or fix `Origin` handling behind your proxy (`CSRF_TRUSTED_ORIGINS`, `X-Forwarded-Proto`).
7. **Expired signed URL.** S3 and CloudFront signed URLs return 403 after expiry or when the signature, region, or clock does not match.
8. **WordPress and plugins.** Inspect `.htaccess` after a permalink or security-plugin change; compare it with WordPress's documented rules and look for an unintended deny directive. [WordPress Apache configuration](https://developer.wordpress.org/advanced-administration/server/web-server/httpd/).

## Common causes by stack

- **Django REST framework:** an authenticated user failing a permission class gets 403; `SessionAuthentication` also returns 403 for unauthenticated permission failures. Check the authentication class order and `permission_classes`. [DRF authentication](https://www.django-rest-framework.org/api-guide/authentication/).
- **Laravel:** a denied gate or policy throws `AuthorizationException`, converted to 403; inspect the policy method and resource ownership. CSRF failures instead map to [419](https://howhttpworks.com/status-codes/419). [Laravel authorization](https://laravel.com/docs/12.x/authorization), [exception handler](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Exceptions/Handler.php).
- **ASP.NET Core JWT bearer:** an authenticated user failing an authorization policy gets 403; check required roles and claims rather than refreshing the same token. [Microsoft JWT guidance](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-jwt-bearer-authentication).
- **AWS ALB / API Gateway:** a WAF web ACL can make ALB return 403; API Gateway's `ACCESS_DENIED`, `INVALID_API_KEY` and `MISSING_AUTHENTICATION_TOKEN` default to 403. The last also covers an unsupported method or resource, so verify the route and stage URL. [ALB troubleshooting](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html), [gateway responses](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html).
- **Kubernetes ingress-nginx:** an external `auth-url` service returning 403 denies the request through `auth_request`; check that service's decision and forwarded credentials. [Ingress annotations](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#external-authentication), [nginx auth_request](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html).

## Fix by stack

### nginx

```nginx
server {
    root  /var/www/site;
    index index.html;                         # missing index => "directory index ... is forbidden"

    location /admin/ {
        allow 203.0.113.0/24;                 # first match wins
        deny  all;                            # everyone else gets 403
    }
}
```

Behind a proxy or CDN, check which address `allow`/`deny` evaluates. Configure `set_real_ip_from` for trusted proxies and `real_ip_header X-Forwarded-For` when restoring the client address. [nginx realip](https://nginx.org/en/docs/http/ngx_http_realip_module.html).

### Apache 2.4

```apache
<Directory /var/www/site>
    # Grant access here; review inherited Require rules too
    Require all granted
</Directory>
```

### Express, Next.js, Django

```javascript
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).set('WWW-Authenticate', 'Bearer').end()
    if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden', required: roles })
    next()
  }
}
```

In Next.js, middleware that returns `new NextResponse(null, { status: 403 })` is not the same as the framework `forbidden()` helper, which is experimental and needs `experimental.authInterrupts` enabled. In Django, `PermissionDenied` produces 403 and renders `403.html`.

### Cloudflare

Check Security > Events for the Ray ID. Allow a legitimate integration with a WAF custom rule using action Skip, scoped to a path plus a secret header or IP, not a blanket disable.

## A token works, but the API still returns HTML

An OAuth token can pass your API's checks while an edge rule rejects the request earlier. If an API call returns `text/html` with a block page, save the response headers and request ID before trying to parse JSON. For Cloudflare, match the Ray ID to the security event; for API Gateway, check the response type and authorizer decision. A 403 from a token endpoint needs that endpoint's policy checked separately from the resource API. [Cloudflare 403 guidance](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-403/).

## Named API failures

For **MongoDB Atlas Administration API** 403s, inspect the returned error code. `ACCESS_FORBIDDEN` identifies access denied to the current user; `IP_ADDRESS_NOT_ON_WHITELIST` identifies a rejected source IP. Check the API key or service account's project access and the Administration API IP access list. A token can be created from an unlisted address yet fail when used for an API call. [Atlas error codes](https://www.mongodb.com/docs/atlas/reference/api-errors/), [programmatic access setup](https://www.mongodb.com/docs/atlas/configure-api-access/).


For a **Vertex AI Search widget** reporting “configuration is not authorized,” check **Integration → Widget**: the page's domain must be configured, including `localhost` during local testing. Confirm the selected authorization type and use the generated snippet's configuration. JWT/OAuth mode needs a token supplied by your backend; Google documents the `https://www.googleapis.com/auth/cloud-platform` scope. [Google widget setup](https://docs.cloud.google.com/generative-ai-app-builder/docs/add-widget).

## Reproduce and verify

```bash
curl -sI https://example.com/admin/ | head -n 5            # status and Server header
curl -sI -A 'Mozilla/5.0' https://example.com/admin/       # UA-based block?
curl -sI -e https://example.com/ https://example.com/img/x.png   # hotlink protection?
curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' https://example.com/
```

## 403 vs 404

Returning 404 for resources a user must not know exist is a legitimate choice (GitHub does it for private repositories), at the cost of harder debugging. Pick one policy and apply it everywhere. See also [401 vs 403](https://howhttpworks.com/compare/401-vs-403), [401](https://howhttpworks.com/status-codes/401), [404](https://howhttpworks.com/status-codes/404), [451](https://howhttpworks.com/status-codes/451) for legally blocked content.

See also the comparison [403 vs 404](https://howhttpworks.com/compare/403-vs-404): when to hide a resource behind a 404 instead of admitting it with a 403.
