ErrLookup › Background articles › Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership
Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership
Permission denied, not authorized, and 403-class errors appear when an access-control check inside the library or service decides the authenticated caller may not perform the action on the target resource. Developers meet this family when calling admin or integration APIs without the right role (Rocket.Chat, Appwrite), hitting cloud IAM denials through a client (Vertex via litellm), calling a tool outside an allowlist (MCP configs), attempting owner-only operations, or writing to a path the process cannot touch (EACCES). This article maps the whole family: where the rejection comes from, how each library phrases it, and which fixes hold across all of them.
Distilled from 116 documented records across 18 repositories.
Background
These errors come from the authorization layer that sits behind authentication. Authentication establishes who the caller is (session user, access key, service account, signed pubkey); authorization then evaluates whether that identity may do the action on the resource. The records show the same shape everywhere: Rocket.Chat evaluates hasPermissionAsync against a role-permission matrix, Appwrite matches the caller's roles against a function's execute permission array, AFFiNE's PermissionService resolves per-doc and per-workspace rules, rustfs runs an IAM layer plus bucket policy for S3 actions over protocol sessions, airi's plugin host checks manifest-declared grants per area/action/key, and SpacetimeDB enforces per-domain ownership. When the evaluation returns false, the request fails before any business logic runs.
From the caller's side the rejection is usually final and often uninformative. The message vocabulary varies widely: HTTP 401/403 ("Unauthorized" in Appwrite, "Access to Method Forbidden", "denied; no ingress cap"), typed error codes (error-action-not-allowed, error-not-authorized, user_unauthorized, no_permission/space_access_denied), or plain untyped strings ("not-authorized", "Not allowed") that REST layers pass through without a code. Some libraries even misclassify the failure: litellm maps a Vertex 403 into BadRequestError because a string-based branch runs before the status-code branches, and anything-llm surfaces a filesystem EACCES as a bare "Internal Server Error" with the real cause only in the server log. Several implementations run the permission check before the existence check (Rocket.Chat's updateOutgoingIntegration), so an unauthorized caller cannot distinguish "not allowed" from "does not exist" — an intentional information hedge.
The family splits along what actually decides the answer. Identity-centric checks ask which roles the caller holds, sometimes scoped to a room, channel, or doc (Rocket.Chat's named permissions, AFFiNE's doc rules). Ownership checks compare the caller to the resource's creator or owner (Rocket.Chat file deletion and own-vs-any integration management; buzz's owner-only group deletion). Policy and allowlist checks match the request against configured grants — rustfs bucket-policy Allow/Deny, openhuman's allowed_tools/disallowed_tools, tailscale's ingress capability, airi's intersected module grants — where intersection and Deny statements can only narrow access. A minority are plain filesystem denials (EACCES/EROFS) wrapped by the application. Two cross-cutting traps recur: permission state can go stale (roles revoked after the client loaded its UI, airi grants persisted for an older manifest), and some systems deliberately split permanent from transient denials — rustfs's AccessDenied versus IamUnavailable — where only the transient sibling is worth retrying.
Common causes
- Caller's role lacks the required permission. The most common form: the authenticated user's roles do not include the specific named permission the check requires. Examples include Rocket.Chat's view-l-room, set-leader, assign-roles, manage-livechat-departments, and add-oauth-service, and Appwrite's execute array not covering the caller's user, team, or key role. Granting the permission, or calling with an identity that holds it, resolves it.
- Operation reserved for the resource owner. The check compares the caller to the resource's creator or owner rather than to a role. Rocket.Chat allows integration management and upload deletion for the creator (or with elevated permission), and buzz rejects group deletes from anyone whose channel membership role is not owner (or the owning human of an owner-role agent).
- No matching grant in an allowlist or policy. The request is matched against configured grants and none covers it: a tool absent from openhuman's allowed_tools or present in disallowed_tools, a missing tailscale ingress capability in the ACL, a rustfs IAM policy with no Allow statement or an explicit Deny, or an airi extension manifest that never declared the area/action/key being called.
- Request authenticates as the wrong identity. The token or key maps to a different user, service account, or identity than the developer assumes. Rocket.Chat checks the token's user, not a body userId; SpacetimeDB domain claims fail under a logged-in wrong identity; anything-llm runs as an OS user that cannot write the .env file.
- Resource-level check stricter than the route-level check. Passing the route guard is not enough. Rocket.Chat's livechat department PUT passes with add-livechat-department-agents but the handler still requires manage-livechat-departments; livechat room message reads additionally require the caller to be that conversation's agent; private-team room listing requires team membership or view-all-teams.
- Cloud IAM or provider 403. A provider-side rejection surfacing through a client library. Vertex answers 403 when the service account lacks roles/aiplatform.user, a Model Garden model was never enabled, or VPC Service Controls blocks the call — and litellm re-wraps it as a BadRequestError with the 403 only in the message text.
- Filesystem permission denied (EACCES/EROFS). A minority of the family is OS-level: the process cannot create or write a path. anything-llm fails to rewrite .env on a read-only mount and returns a generic 500; tailscale's ChonkDir cannot mkdir the TKA state directory. The real errno (EACCES, EROFS, ENOSPC) is in the log.
- Payload unintentionally triggers a permission diff. Sending fields you did not mean to change can fire the gate. Rocket.Chat's users.update treats any roles-array difference — removal included — as role editing requiring assign-roles; self-edits carrying the verified flag are rejected outright; Custom_Script_* ids fail bulk settings saves on cloud workspaces.
What usually fixes it
- Identify the exact permission named in the error or its documentation and grant it to the calling identity — or re-run the operation with an identity that already holds it. Refresh sessions, tokens, and role caches afterward, since clients keep stale permission state after server-side changes.
- Verify who the request actually authenticates as before touching policies: resolve the token's user, the active service account or identity, and for filesystem errors the OS user the process runs as.
- Audit the resource side, not just roles: bucket-policy Deny statements, allowlists and capability grants, a function's execute array, domain ownership. An identity-side grant cannot override an explicit Deny or a missing allowlist entry, and intersections only ever narrow effective grants.
- Send only the fields you intend to change. Omit roles, verified, and privileged setting ids from payloads so permission diffs, self-service gates, and cloud-blocked settings do not reject the whole request.
- For filesystem denials, read the wrapped OS error (EACCES, EROFS, ENOSPC) and fix ownership, mounts, or volumes rather than retrying the call.
- Pre-check with non-throwing APIs where offered (canDoc/canWorkspace, permission lookups, filtered lists) and treat denials as permanent unless the library names a transient sibling, such as rustfs's IamUnavailable.
Go deeper
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
- HTTP status errors: handling 4xx and 5xx responses — how to handle 4xx and 5xx responses properly.
Documented occurrences
- Access denied (rustfs/rustfs)
- not-authorized (RocketChat/Rocket.Chat)
- error-not-authorized: Not authorized (RocketChat/Rocket.Chat)
- error-action-not-allowed: Editing settings is not allowed (RocketChat/Rocket.Chat)
- Permission denied: ${details.area}.${details.action} "${details.key}" (moeru-ai/airi)
- error-user-lacks-message-impersonate-permission: User selected for the incoming integration lacks the 'message-impersonate' permission. (RocketChat/Rocket.Chat)
- Internal Server Error (Mintplex-Labs/anything-llm)
- error-action-not-allowed: Editing email verification is not allowed (RocketChat/Rocket.Chat)
- error-not-authorized (RocketChat/Rocket.Chat)
- {custom_llm_provider.capitalize()}Exception BadRequestError - {error_str} (BerriAI/litellm)
- error-action-not-allowed: Adding OAuth Services is not allowed (RocketChat/Rocket.Chat)
- not_authorized: Unauthorized (RocketChat/Rocket.Chat)
- 403: Access to Method Forbidden (RocketChat/Rocket.Chat)
- MCP tool `{tool}` is not allowed for server `{}` (tinyhumansai/openhuman)
- user-not-on-private-team (RocketChat/Rocket.Chat)
- error-not-authorized-federation: Not authorized to access federation (RocketChat/Rocket.Chat)
- error-action-not-allowed: Assign roles is not allowed (RocketChat/Rocket.Chat)
- error-not-authorized: Not authorized (RocketChat/Rocket.Chat)
- error-not-allowed: Not allowed (RocketChat/Rocket.Chat)
- error-user-is-not-agent: error-user-is-not-agent (RocketChat/Rocket.Chat)
…and 96 more across the corpus — use search.
Honest provenance: generated on 2026-08-19 from AI-assisted analysis of the linked records. See how records are made.