Quick answer: A 403 with a token that otherwise works means the request authenticated successfully but is not allowed to do this specific action. The usual cause is a missing or mismatched scope: the token proves who the caller is, but it does not carry the permission this endpoint demands. Do not chase the login flow. Decode the token, list the scopes it actually has, and compare them against the scope the operation requires.
401 and 403 are different failures
Treat the status code as the first diagnostic because it tells you which layer rejected the call.
| Status | Meaning | Where to look |
|---|---|---|
| 401 Unauthorized | The request is not authenticated | Missing, expired, malformed, or wrong-audience token |
| 403 Forbidden | The request is authenticated but not permitted | Scope, role, tenant, resource ownership, or policy |
A 403 means the gateway already accepted the token. Re-issuing the same kind of token, refreshing it, or fixing the signature will not help, because the new token will carry the same narrow permissions. The fix is in what the token is granted, not whether it is valid.
Step one: decode the token and read the scopes
JWTs are base64, not encrypted, so the payload can be read without a secret. Decode the middle segment and look at the claims that carry permissions. The exact name varies by provider: scope, scp, or a custom claim.
A scope claim often arrives as a space-delimited string, such as openid profile invoices:read, while other systems use an array. Write down the exact values. Scope strings are exact matches, so invoices:read does not grant invoices:write, and a wildcard policy on the server does not rescue a token that never asked for the scope.
While you are in the payload, check the audience as well. A token minted for a different service can be valid and still get refused. That is a separate problem from scope, but it produces the same 403 and is cheaper to catch now.
Step two: find the scope the endpoint actually requires
The required scope lives on the server's route definition, not in the client. Check the API gateway configuration or the operation's security metadata. Many teams document this on the OpenAPI operation using a security scheme, which is the most reliable place to read it because the docs and the enforcement share a source. A tool that reads that same operation, like Powerduck, shows the required scope next to the request instead of leaving it buried in gateway config.
Compare the two lists as a set operation. You are looking for a required scope that is absent from the token, not for a general feeling that the token "has a lot of scopes already." A token holding ten permissions is still refused on the one operation that needs an eleventh.
Step three: reproduce the mismatch cleanly
Before changing anything, prove the cause with a controlled request rather than editing config on a hunch.
- Send the failing request and capture the exact 403 body. Some servers return a structured error naming the missing scope, which closes the case immediately.
- Mint a token that explicitly includes the required scope, leave every other parameter unchanged, and replay the same call.
- If the second request returns 200, the scope was the cause. If it still returns 403, the gate is something else: role, tenant, or a resource-level ownership check.
This isolation matters. Adding scopes is not free, and broadening a token to "make the error go away" without proving the requirement is how clients end up holding write permissions they never needed.
The common causes beyond a simple missing scope
If the token already lists the required scope and the call still fails, work through the narrower cases.
-
Scope naming drift. The client requests
invoice:readwhile the route expectsinvoices:read. Both look correct in isolation; compare character by character. - Environment mismatch. A token minted against the staging issuer carries staging scopes and is presented to production, which uses different identifiers.
- Role gates above scopes. The scope is present, but the user's role is not allowed to use it. Scopes and roles are two checks, and passing one does not pass the other.
- Resource ownership. The caller can read invoices generally but not this specific invoice owned by another tenant. That returns 403 even though every scope is in order.
Frequently asked questions
My token has admin scope. Why do I still get 403? Verify the admin claim is actually in this token rather than assumed from the account. Then check for a resource-level policy that runs after the scope check; admin scope does not always override tenant ownership.
Should I just request every scope at login to avoid this? No. Broad tokens increase the blast radius if the client is compromised and often fail provider review. Request the scopes for the action the user is taking and use incremental consent when the app needs more.
The 403 body is empty. How do I find the missing scope? Decode the token, list the operation's required scope from the gateway config, and diff the two. If you cannot read the gateway config, mint a token with the documented scope and replay to confirm.
Is a forbidden response ever caused by CORS? Not by itself. CORS controls what the browser lets JavaScript read, not whether the operation is permitted. A 403 in the response is an authorization decision; the browser's CORS error is a separate message about visibility.
The bottom line
When a valid token returns 403, the authentication layer has already done its job, so restarting the login flow is wasted effort. The fast path is to decode the token, read the exact scopes, and diff them against the one permission the endpoint enforces, then prove it with a single replayed request. Keep the fix narrow and treat scope strings as exact values. That discipline keeps tokens limited to what each action needs and stops you from widening permissions to silence an error you never fully diagnosed. More of this method is in the API debugging guide.
Top comments (0)