How HTTP Works
4xx · Client error
< HTTP/1.1 403 Forbidden

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.

Reviewed 6 min readbeginner5 sourcesTry itMarkdown
Cacheable
Only with explicit freshness
Retry?
No: different credentials may help, a retry will not
Usually sent by
Origin app, WAF or CDN
Spec
RFC 9110 §15.5.4
Often confused with
401, 404
On this page

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, 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.

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

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.
  • 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.
  • 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.
  • curl -f: curl: (22) The requested URL returned error: 403. Use curl -i without -f while inspecting the error body. curl source.
  • .NET: HttpRequestException with the English message Response status code does not indicate success: 403 (Forbidden). after EnsureSuccessStatusCode(). Runtime message template.
  • Spring WebClient: WebClientResponseException$Forbidden: 403 Forbidden from POST https://api.example.com/resource with retrieve(). Read getResponseBodyAsString(). Spring 6.2 source.
  • 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, exception subclasses.

Who sent it?

EvidenceLayer
Server: cloudflare, CF-Ray, page titled “Attention Required! | Cloudflare” with a Ray IDCloudflare 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 directoryApache Require/permissions
Request blocked. We can't connect to the server for this app or website at this time with X-Cache: Error from cloudfrontCloudFront 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 rulesModSecurity 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.

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.
  • Laravel: a denied gate or policy throws AuthorizationException, converted to 403; inspect the policy method and resource ownership. CSRF failures instead map to 419. Laravel authorization, exception handler.
  • 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.
  • 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, gateway responses.
  • 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, nginx auth_request.

Fix by stack

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.

Apache 2.4

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

Express, Next.js, Django

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.

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, programmatic access setup.

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.

Reproduce and verify

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, 401, 404, 451 for legally blocked content.

See also the comparison 403 vs 404: when to hide a resource behind a 404 instead of admitting it with a 403.

In practice

Express.js
// Role-based access control middleware
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'Authentication required' })
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        message: `Requires role: ${roles.join(' or ')}`
      })
    }
    next()
  }
}

app.delete('/api/users/:id', requireAuth, requireRole('admin'), async (req, res) => {
  await db.users.delete(req.params.id)
  res.status(204).end()
})
Next.js App Router
// app/api/admin/users/route.ts
export async function DELETE(request: Request) {
  const user = await getAuthUser(request)
  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } })
  }
  if (user.role !== 'admin') {
    return Response.json(
      { error: 'Forbidden', message: 'Admin access required' },
      { status: 403 }
    )
  }
  // Proceed with deletion...
  return new Response(null, { status: 204 })
}
Django
from django.http import JsonResponse, HttpResponse
from django.contrib.auth.decorators import login_required

def require_role(*roles):
    def decorator(view_func):
        @login_required  # redirects anonymous users to the login URL (302), not 401
        def wrapper(request, *args, **kwargs):
            if request.user.role not in roles:
                return JsonResponse(
                    {'error': 'Forbidden', 'message': f'Requires role: {roles}'},
                    status=403
                )
            return view_func(request, *args, **kwargs)
        return wrapper
    return decorator

@require_role('admin')
def admin_delete_user(request, user_id):
    User.objects.filter(pk=user_id).delete()
    return HttpResponse(status=204)

Frequently asked questions

How can I tell whether a 403 is temporary?

The status alone has no expiry time. Check the rule or policy decision and its logs: a changed WAF rule, role assignment or signed URL may resolve it, but repeated requests with the same credentials should not be retried automatically.

Should an admin role check return 403 after successful authentication?

Yes. If the user is authenticated but lacks the required admin role, return 403. Check the assigned role or authorization policy; refreshing a token with the same permissions will not fix that denial.

What does 403 Forbidden mean?

The server understood the request and refuses to authorize it (RFC 9110 section 15.5.4). It applies whether or not credentials were sent, and clients should avoid automatically repeating the same request with the same credentials.

What is the difference between 401 and 403?

401 says authentication is missing or invalid and must include a WWW-Authenticate challenge; supplying valid credentials may help. 403 says the server refuses regardless, usually because the authenticated user lacks permission or a rule blocks the request.

Why do I get 403 Forbidden on my own nginx or Apache site?

Typically the web server user cannot read the file or traverse a parent directory, there is no index file and directory listing is off (nginx logs directory index of ... is forbidden), or an allow/deny rule matched. Apache 2.4 logs AH01630 when Require rules deny the request.

Why do I get 403 from Cloudflare?

A firewall custom rule, managed WAF rule, IP Access Rule, or Bot Fight Mode blocked the request. The block page shows a Ray ID you can search for under Security Events to see which rule matched.

Should I use 403 or 404 for resources a user cannot access?

Use 403 when revealing that the resource exists is acceptable. Use 404 when existence itself is sensitive; RFC 9110 explicitly allows origin servers to use 404 to hide a forbidden resource.

Sources

  1. MDN Web Docs: 403 Forbiddendeveloper.mozilla.org
  2. RFC 9110 section 15.5.4: 403 Forbiddenrfc-editor.org
  3. Cloudflare WAF documentationdevelopers.cloudflare.com
  4. nginx: ngx_http_access_modulenginx.org
  5. Apache httpd: Access Controlhttpd.apache.org

Keep going

Browse /search