permission_denied
HTTP 403 retryable: false REST and MCP
The credential resolved to a valid principal, and that principal's current authority does not include the action you asked for.
Why it happens
Authority is recomputed on every request. The bearer token is a lookup handle, not a claim set: the server re-reads the credential's scopes from current database state and intersects them with the current grant of the human who authorised it, so revoking a person's access takes effect on the next request made by every credential they created. The single most common trigger is the scope check, which compares the required scope against that recomputed set. The most surprising ones are the creation-time checks on new credentials, which refuse to mint authority the creator does not already hold.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- The recomputed scope set does not contain the scope the route requires. There are 32 scopes and each route names the one it needs.
packages/domain/src/authorize.ts:7 - A route that only a human member may call was reached by an API key or a connection grant.
packages/domain/src/authorize.ts:37 - Creating a credential that requests a scope the creator does not hold. A credential cannot exceed its creator, and this fails at creation rather than later at use.
packages/domain/src/services.ts:616 - Creating a credential whose expiry is in the past or later than the parent credential's own expiry.
packages/domain/src/services.ts:627 - A non-human principal creating a credential for the other environment. An efa_test_ key cannot mint an efa_live_ one.
packages/domain/src/services.ts:634 - An API key or any non-connection principal calling a tool at the MCP endpoint.
packages/mcp/src/index.ts:222 - A tools/call naming a tool that the grant's scopes do not cover. The same filter that builds tools/list re-runs on the call.
packages/mcp/src/index.ts:284 - Sending against an approval that is expired, already consumed, bound to a different payload hash, or requested by a different principal.
packages/domain/src/services.ts:1769 - Sending from an inbox whose status is not active.
packages/domain/src/services.ts:1784 - The credential's environment does not match the target inbox's environment.
packages/domain/src/services.ts:4270 - Any call against a workspace in the deleting state. Access is tombstoned rather than partially served.
packages/domain/src/services.ts:921
How to fix it
- Call GET /me and compare the scopes it reports against the one the route needs. The response is the recomputed set, which is the set that will actually be used.
- If the credential was fine yesterday, check whether the human who created it lost a role. Authority is intersected with that person's current grant on every request.
- When creating a key, request only scopes you hold yourself. The check runs at creation, so a key that was minted successfully never carries a scope its creator lacked.
- At the MCP endpoint, complete an OAuth connection grant. An API key cannot be made to work there by adding scopes to it.
- For an approval failure, request a fresh approval. An approval is bound to a payload hash, so editing the draft after approval invalidates it by design.
Why it works this way
An API key is rejected at the MCP endpoint by design, and this is the clearest example of the model. A bearer key carries no human behind it, so there is nothing to intersect the per-request authority check against. A connection grant always names the person who authorised it, which is what makes revocation and the per-request recomputation meaningful. Adding scopes to a key would not fix this, because the missing thing is not a scope.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
These are the documented surfaces that raise it. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
GET /mePOST /projects/{project_id}/keysPOST /projects/{project_id}/inboxes/{inbox_id}/messagesPOST https://mcp.emailforagents.ai/mcp
The response
Read request_id off the body or the x-request-id header before
doing anything else. It is the only handle on one specific attempt, and for a 503 it is
the only handle at all, because the underlying detail stays server-side.
HTTP/1.1 403
content-type: application/json
x-request-id: req_...
{
"error": {
"code": "permission_denied",
"message": "This credential cannot perform that action.",
"request_id": "req_...",
"retryable": false
}
}
Verified against packages/domain/src/authorize.ts:7. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.