HTTP 403 Forbidden: Why You Are Being Denied and How to Get Access
Quick answer
💡HTTP 403 Forbidden means the server understood your request and identified who you are, but has decided you do not have permission to access the resource. This is different from 401 Unauthorized, which means the server does not know who you are because credentials are missing or invalid. Common 403 causes are a missing or expired Bearer token, a token with insufficient OAuth scopes, an IP allowlist blocking your source address, or a role that has not been granted the right permission in RBAC.
Error symptoms
- ✕
HTTP 403 status code with a response body like Forbidden, Access Denied, or Insufficient permissions - ✕
API returns 403 even though the same token works on other endpoints - ✕
Request succeeds from one environment but 403s from another IP address or VPC - ✕
Browser request 403s but the same curl command with the same token succeeds - ✕
After a token refresh, 403 continues because the new token has the same insufficient scope - ✕
Console shows 403 on the OPTIONS preflight, blocking CORS before the real request is sent
Common causes
- •Authorization header is missing entirely or uses incorrect format — must be Authorization: Bearer TOKEN with capital B
- •OAuth 2.0 token was issued with read scope but the endpoint requires write scope
- •Source IP is not on the API's allowlist — AWS security groups, GCP firewall rules, or application-level IP restrictions
- •User account exists and authenticates successfully but has not been assigned the required role in RBAC
- •CSRF token is missing on a state-mutating request (POST, PUT, DELETE) to a web application using session-based auth
- •A reverse proxy or load balancer is stripping the Authorization header before the request reaches the application
When it happens
- •Integrating with a third-party API and using a token generated with default (minimal) scopes
- •Deploying to a new environment with a different egress IP that is not whitelisted by the API
- •Adding a user to the system but forgetting to assign them a role in the admin panel or database
- •Building a frontend that calls a state-changing API endpoint without including the CSRF token from the session cookie
- •Setting up AWS API Gateway with IAM authorization and calling the endpoint without SigV4 signing
Examples and fixes
Two of the most common 403 causes: wrong Authorization header format and a token with insufficient scope.
Bearer token format error and scope mismatch
❌ Wrong
// Wrong: lowercase 'bearer', missing space, wrong scope token
const response = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: {
// Common mistakes: 'bearer' (lowercase), or 'Bearer: TOKEN'
'Authorization': 'bearer ' + readOnlyToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ item: 'widget', quantity: 5 })
});
// Returns 403 Forbidden✅ Fixed
// Step 1: Request a token with the correct scope
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'orders:write' // Must include write scope for POST
})
});
const { access_token } = await tokenResponse.json();
// Step 2: Use correct Bearer format
const response = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`, // Capital B, space, then token
'Content-Type': 'application/json'
},
body: JSON.stringify({ item: 'widget', quantity: 5 })
});Two separate bugs appear in the broken version. First, the Authorization header uses lowercase 'bearer' — the HTTP specification requires 'Bearer' with a capital B, and strict API servers reject lowercase. Second, the token was requested with a read-only scope and is being used on an endpoint that requires write permission. OAuth 2.0 scopes are enforced server-side: even a valid, non-expired token will receive a 403 if it lacks the scope required for the operation. The fix requests a new token explicitly including the orders:write scope in the client_credentials grant. Always decode your token at jwt.io or with a JWT inspector tool to verify which scopes it actually contains before assuming scope is correct.
A web application using session-based auth requires a CSRF token on every POST, PUT, and DELETE request.
CSRF token missing on state-mutating request
❌ Wrong
// Frontend JavaScript — missing CSRF token
async function deleteComment(commentId) {
const res = await fetch(`/api/comments/${commentId}`, {
method: 'DELETE',
credentials: 'include' // Sends session cookie
// Missing: X-CSRF-Token or _csrf header
});
// Server returns 403 Forbidden due to missing CSRF token
return res.json();
}✅ Fixed
// Read CSRF token from meta tag (server renders it in the HTML)
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content;
}
async function deleteComment(commentId) {
const res = await fetch(`/api/comments/${commentId}`, {
method: 'DELETE',
credentials: 'include', // Sends session cookie
headers: {
'X-CSRF-Token': getCsrfToken(),
'Content-Type': 'application/json'
}
});
if (res.status === 403) {
const body = await res.json();
// Log the 403 body — it may describe the specific reason
console.error('403 Forbidden:', body);
throw new Error(`Access denied: ${body.message || '403 Forbidden'}`);
}
return res.json();
}Web frameworks like Rails, Django, Laravel, and Express with csurf use CSRF tokens to prevent cross-site request forgery. The server issues a token that is embedded in the page — typically in a meta tag or a hidden form field — and requires that token to be sent back as a header on any state-changing request. Sending the session cookie alone is not sufficient. The browser automatically sends cookies with credentials: 'include', but the JavaScript must explicitly read and send the CSRF token in a header. The reason CSRF tokens protect against cross-site attacks is that a third-party site cannot read the CSRF token from your page's DOM due to the same-origin policy. The fix reads the token from the meta tag that the server renders and includes it in the X-CSRF-Token header on every DELETE, POST, and PUT request.
An API Gateway endpoint requires AWS SigV4 request signing but the client sends an unsigned request.
AWS API Gateway IAM authorization missing
❌ Wrong
# curl — unsigned request to IAM-protected endpoint
curl -X GET \
'https://abc123.execute-api.us-east-1.amazonaws.com/prod/users' \
-H 'Content-Type: application/json'
# Returns: {"message": "Forbidden"}✅ Fixed
# curl with AWS SigV4 signing using aws-sigv4-proxy or awscurl
awscurl --service execute-api \
--region us-east-1 \
-X GET \
'https://abc123.execute-api.us-east-1.amazonaws.com/prod/users' \
-H 'Content-Type: application/json'
# In Node.js using @aws-sdk/signature-v4:
# const signer = new SignatureV4({ credentials, region, service: 'execute-api', sha256: Sha256 });
# const signed = await signer.sign({ method: 'GET', hostname, path, headers, body });
# Then use signed.headers in your fetch callAWS API Gateway endpoints configured with IAM authorization require every request to be signed with AWS Signature Version 4 (SigV4). An unsigned request receives 403 Forbidden with the message 'Forbidden', which is distinct from 401 because the identity cannot even be determined without a valid signature. SigV4 signs the entire request — URL, headers, and body — with your AWS access key and secret key. The signature is included in the Authorization header in a specific format that looks different from a Bearer token. Use the AWS SDK's SignatureV4 class in Node.js or the awscurl CLI tool for testing. Alternatively, use a Cognito User Pool or Lambda authorizer if you want standard Bearer token auth rather than SigV4.
What 403 Forbidden means vs 401
The distinction between 401 and 403 is one of the most misunderstood aspects of HTTP. A 401 Unauthorized response means the server cannot identify who is making the request — credentials are missing, expired, or invalid. A 403 Forbidden response means the server knows exactly who you are but has decided you are not permitted to access the resource. The authorization step succeeded (or was irrelevant), but the permissions check failed.
Practically, this distinction changes how you debug. If you receive a 401, check whether the Authorization header is present, whether the token is expired, and whether the token format is correct. If you receive a 403, the token is likely valid and recognized — but the account, role, or token scope does not grant access to the specific resource being requested. Decoding the JWT token with a tool and inspecting the scope, roles, and sub claims is the most direct way to confirm what permissions the token carries.
OAuth 2.0 scope mismatches are responsible for a large percentage of 403 responses in API integrations. Scopes are defined by the API provider and specify which operations a token permits. A token issued with openid profile email is appropriate for reading user identity but will receive 403 on an endpoint requiring write:data or admin. The OAuth 2.0 authorization server issues tokens with the scopes listed in the original token request — if you forgot to include a scope, the only fix is to request a new token with the correct scope.
Role-Based Access Control (RBAC) adds another layer. A user can have a valid, unexpired token with all the right scopes — and still receive 403 if their account has not been assigned the required role in the application's authorization system. This is separate from OAuth scopes. The token says who the user is; the application's RBAC system says what they can do. Administrators assign roles in a database table, an admin panel, or an IAM configuration. A new user who can authenticate successfully but gets 403 everywhere usually has not been granted any role.
IP-based restrictions produce 403 responses that appear identical to permission errors. AWS Security Groups, GCP VPC firewall rules, nginx allow/deny rules, and application-level IP allowlists all return 403 when the source IP is not permitted. These restrictions are completely invisible to the client — the 403 response body is identical whether the problem is an IP block or a role mismatch, making them frustrating to diagnose.
Reading the 403 response to find the cause
Open DevTools in the browser and navigate to the Network tab before making the request that returns 403. Click on the failing request and examine three panels in sequence: Headers, Response, and Preview. In the Headers panel, look at the Request Headers section to confirm the Authorization header is present. Check the exact value — it should be Bearer followed by a space and then the token string. Copy the token value and decode it at a JWT inspector tool to read the payload: check the exp claim to confirm it is not expired, the scope or scopes claim for what permissions are granted, and the sub or email claim to confirm the right account's token is being used.
In the Response panel, read the body of the 403 response carefully. Well-designed APIs include a machine-readable error code and human-readable message: something like insufficient_scope, ip_not_allowed, or role_required. These error details are often the fastest path to the root cause. Poorly designed APIs return just Forbidden with no details, which requires more investigative work.
For CORS-related 403s, the OPTIONS preflight request may be returning 403 before the real request is sent. In DevTools, filter by OPTIONS and check whether the preflight response is 403. This means an authentication or WAF layer is blocking the preflight. CORS preflights are sent without credentials, so if your auth middleware requires a valid token on every request, it will reject OPTIONS before CORS headers can be applied. See the /tools/cors-tester on ToolDock to send an OPTIONS request and inspect the response headers independently.
For IP allowlist issues, the diagnostic is to try the same request from a different network. If the request succeeds from a VPN, from a corporate network, or from a cloud server but fails from your local machine, IP restrictions are the cause. AWS Security Groups can be inspected in the console — look for inbound rules on the API's port that list specific CIDR ranges. Contact the API owner to add your IP or your NAT gateway's static IP to the allowlist.
Use the /tools/http-request-builder on ToolDock to replay the exact request with custom headers. This lets you test different Authorization header values, add or remove custom headers, and confirm whether the 403 is header-dependent without modifying your application code.
Fixing each type of 403 Forbidden error
For Bearer token format errors, confirm the header value follows the exact pattern: Authorization: Bearer TOKEN where Bearer is capitalized, there is exactly one space between Bearer and the token, and the token itself has no trailing whitespace or newline characters. String interpolation in JavaScript template literals can introduce newlines at the end of tokens if the environment variable includes one. Trim the token value when reading from environment variables: process.env.API_TOKEN.trim().
For OAuth scope errors, re-authorize the application with the correct scopes before requesting a new token. With client_credentials grants, include scope as a parameter in the token request. With authorization code grants, the scope must be included in the initial authorization redirect URL — the user must re-authenticate and approve the new scope. After getting a new token, decode it and confirm the scope claim now includes the required value before making API calls.
For RBAC permission errors, locate where role assignments are managed for the application. This is usually a database table like user_roles, an admin dashboard, or an IAM policy document. The user or service account making the request must be assigned the role that grants the specific permission. In AWS, IAM policies must include an explicit Allow for the specific action on the specific resource ARN — a user can have admin access to S3 but still receive 403 on API Gateway without an explicit API Gateway policy.
For IP allowlist restrictions, determine the egress IP of the client making the request. In a cloud deployment, this is typically a NAT gateway IP. Request the API owner add this IP to their allowlist. For development purposes, use a VPN to route traffic through an already-whitelisted IP, or set up an API proxy service on an allowed IP that forwards requests. For production, use static IP addresses or Elastic IPs to ensure the egress IP is predictable and allowlistable.
For CSRF token errors in web applications, ensure the token is read from wherever the server renders it — commonly a meta tag in the page head or a cookie named XSRF-TOKEN. Include the token in the appropriate header for your framework: X-CSRF-Token for Rails and Rack apps, X-XSRF-TOKEN for Angular's HttpClient, and a custom header for Express with csurf. The token must match what the server issued for the current session; refreshing the page regenerates the session token and invalidates any previously read value.
Proxy stripping and WAF blocking 403s
Reverse proxies are a frequent but invisible source of 403 errors. Some nginx configurations strip the Authorization header before forwarding requests to the upstream application. This is sometimes intentional — the proxy handles authentication itself and the upstream app trusts all proxied requests. But when the upstream app also requires the Authorization header, the proxy's stripping causes 403. Check nginx configuration for proxy_set_header Authorization '' or proxy_hide_header Authorization directives that may be removing the header.
AWS Application Load Balancer (ALB) and API Gateway have their own authentication mechanisms that run before the request reaches the application. ALB listener rules can authenticate with Cognito or OIDC and deny unauthenticated requests with 401 or redirect them. If the ALB auth is misconfigured or the token does not match the expected audience, the ALB returns 403 before the request reaches the Lambda or ECS container. These 403 responses come from the ALB infrastructure, not the application code, so they do not appear in application logs.
WAF (Web Application Firewall) rules are another source of 403 that is invisible to the application. AWS WAF, Cloudflare WAF, and other WAF products can block requests that match attack signatures. A request body that contains SQL-like syntax, a URL with special characters, or an unusual User-Agent can all trigger WAF rules and produce a 403 that the application never sees. WAF logs are separate from application logs and must be checked independently. Testing with /tools/http-request-builder from a fresh IP with minimal headers can help isolate whether WAF fingerprinting is the cause.
Same-origin policy and CORS interact with 403 in a way that confuses browser developers. If an API returns 403 on the OPTIONS preflight, the browser does not forward the actual request and reports a CORS error instead of a 403. The developer sees a CORS error in the console but the real problem is that the preflight received 403 from an authentication or WAF layer. Examining the OPTIONS request's response status in DevTools reveals the 403, which indicates auth middleware is running on OPTIONS requests that should be allowed through without authentication.
Token expiry in long-lived sessions creates 403 bursts. OAuth access tokens typically expire after 1 hour. If the client does not implement token refresh, requests succeed for the first hour and then all start receiving 403. Implement automatic token refresh using the refresh_token grant type when a 401 or 403 is received, then retry the original request with the new token. Be careful not to refresh on every 403 — only when the token is confirmed expired by checking the exp claim.
Common 403 mistakes that waste hours
Assuming the token is correct without decoding it is responsible for hours of wasted debugging time. Developers see 403, change environment variables, restart services, and try again — without ever checking whether the token being sent actually contains the right scopes. Every debugging session for a 403 should start by copying the token from the Authorization header, decoding it at a JWT inspector, and verifying exp, scope, sub, and aud claims before touching any code or configuration.
Confusing 401 and 403 leads to the wrong fix. A 401 means re-authenticate or send credentials. A 403 means you are authenticated but not authorized — sending the same credentials again will not help. When an API returns 403 and a developer responds by refreshing the token repeatedly, they waste time without making progress. The access level of the token, not its freshness, determines whether a 403 will recur.
Using a development token in production or vice versa generates 403 errors that are correct behavior but appear as bugs. A token issued against the development OAuth app with read-only scope will 403 against the production API that requires write scope. Always verify which environment your token was issued for and which environment the API call is targeting. Environment variable names like API_TOKEN without a suffix are particularly prone to this mistake.
Not checking whether a middleware is running in the correct order in Express causes 403s that appear random. If the authorization middleware runs before the CORS middleware, OPTIONS preflight requests get 403d before CORS headers are added. If rate-limiting middleware runs before authentication, unauthenticated requests may hit the rate limit and receive 429 instead of 403, masking the actual error. Middleware order in Express is positional — earlier registrations run first, and this order must be deliberate.
Forgetting to update the allowlist when changing deployment infrastructure is a production 403 incident waiting to happen. When migrating from on-premises to cloud, changing cloud regions, adding a NAT gateway, or scaling to multiple availability zones, egress IPs change. Any API with IP-based allowlisting will start returning 403 for all requests from the new infrastructure until the allowlist is updated. Include IP allowlist updates in the deployment runbook for any infrastructure change.
Building authorization that works reliably
Implement centralized token management in a singleton service rather than managing tokens in each API client. The service holds the current access token, tracks its expiration from the exp claim, and proactively refreshes it 60 seconds before it expires. All API clients request tokens from this service rather than managing their own. This eliminates token expiry races and ensures the correct scopes are always requested for the environment.
Log every 403 response with the request URL, the HTTP method, the token's sub and scope claims if available, and the full response body. These four pieces of data together usually identify the cause in under 60 seconds: scope shows if it is a permission issue, sub confirms the right account, URL confirms the right endpoint, and the response body may carry the specific reason code. Without this logging, a 403 in production requires re-creating the exact request to diagnose.
Use the principle of least privilege when requesting OAuth scopes. Request only the scopes an application actually needs, not broad admin scopes for convenience. This makes scope-related 403s much easier to diagnose because the token's scopes clearly reflect the application's intended access level. When a new feature requires a broader scope, it is a deliberate decision that triggers a re-authorization flow rather than a silent expansion of existing tokens.
Test authorization boundaries explicitly in your integration test suite. Write tests that send requests with expired tokens, tokens with insufficient scope, requests without Authorization headers, and requests from IP addresses not on the allowlist (if testable in your CI environment). These tests catch authorization regressions before they reach production. The /tools/http-request-builder on ToolDock is useful for manually constructing test requests with specific header values to verify authorization behavior before writing automated tests.
For APIs that return 403 with generic messages, build a diagnostic helper that decodes the JWT and compares the token's scopes against the required scope for each endpoint. This can be a simple developer-only debug page or a CLI tool that prints: token issued for user X, token scopes are Y, endpoint Z requires scope W, conclusion: token is missing scope W. This kind of tooling turns a 403 from a mystery into a one-line diagnosis.
Quick fix checklist
- ✓Decode the JWT token and check exp (not expired), scope (contains required permission), and sub (correct account)
- ✓Verify Authorization header format: must be 'Bearer TOKEN' with capital B, one space, no trailing whitespace
- ✓Check DevTools Network tab — confirm Authorization header is actually present in the outgoing request
- ✓For OAuth scope errors, request a new token explicitly including the required scope in the grant request
- ✓Verify the user or service account has been assigned the required role in RBAC, not just created
- ✓Test the same request from a different network to identify IP allowlist restrictions
- ✓For web apps with session auth, include the CSRF token in X-CSRF-Token header on POST, PUT, and DELETE requests
- ✓Check for reverse proxy header stripping by adding request logging on the upstream application
Related guides
Frequently asked questions
What is the difference between 401 Unauthorized and 403 Forbidden?
401 Unauthorized means the server cannot identify who is making the request — credentials are missing, malformed, or expired. 403 Forbidden means the server identified you but decided you do not have permission. If you receive a 401, fix your credentials. If you receive a 403, your credentials are valid but your account or token lacks the required permission, scope, or role for the specific resource.
Why does the same request work in Postman but return 403 from my code?
Compare the Authorization header value byte-for-byte between Postman and your code. Common differences: your code reads a token from an environment variable that has a trailing newline or whitespace, your code uses lowercase 'bearer' instead of 'Bearer', or your code sends a different token than Postman (development vs production token). Also check whether your code sends additional headers that Postman does not, as some WAF rules block requests based on header patterns.
How do I check what scopes my OAuth token has?
Decode the token as a JWT: split it at the dots, base64-decode the middle segment (the payload), and parse the resulting JSON. Look for the scope or scp claim — it will list the scopes the authorization server granted. If the required scope is missing, re-request the token with the correct scope parameter in the token request. Tools like jwt.io or ToolDock's JWT decoder make this a one-step operation.
My API returns 403 only from certain servers — what could cause that?
Almost certainly an IP allowlist. The server's IP or the deployment's NAT gateway IP is not on the API's allowed list. Compare the egress IP of the failing servers against the servers that succeed. Check AWS security groups, GCP firewall rules, or any application-level IP restriction configuration on the API side. Request the API maintainer add the new IP range to the allowlist, or route all traffic through a fixed NAT gateway IP.
How do I fix a 403 on an AWS API Gateway endpoint?
AWS API Gateway has multiple authorization layers. Check which authorizer type is configured: IAM, Cognito User Pool, Lambda authorizer, or none. For IAM authorization, requests must be signed with SigV4. For Cognito, the Authorization header must contain a valid Cognito JWT. For Lambda authorizers, check the authorizer Lambda's logs. Also check the API Gateway resource policy for source IP restrictions and ensure the IAM role or user has execute-api:Invoke permission on the endpoint ARN.
Why does my CSRF token fix work in development but fail in production?
CSRF tokens are session-specific and must match what the server issued for the current browser session. In production, if the frontend and backend are on different domains, the server's Set-Cookie for the session may not be sent to the browser due to SameSite cookie restrictions. Verify SameSite=None with Secure is set on the session cookie for cross-domain setups. Also confirm the CSRF token meta tag is being rendered in the page HTML that JavaScript reads it from.
Can a CORS error and a 403 error happen together?
Yes. If an API returns 403 without CORS headers, the browser reports a CORS error rather than showing the 403 status code, because JavaScript cannot read the response at all. The Network tab in DevTools shows the 403 status code directly on the request even when JavaScript cannot access it. A proper API should return CORS headers on all responses including error responses so the browser can let JavaScript read the 403 body and display a meaningful error.
How does a reverse proxy stripping Authorization headers cause a 403?
Some reverse proxies are configured to strip or replace the Authorization header before forwarding requests to the upstream application. This can be intentional for proxies that handle authentication themselves, or accidental from a misconfigured proxy_hide_header directive in nginx. When the upstream app receives the request without an Authorization header, it returns 403. Check nginx configuration for proxy_set_header Authorization '' or proxy_hide_header Authorization, and verify the header is present on requests as they arrive at the application using request logging middleware.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.