ruvnet/RuView · error · HTTPException
Not enough permissions. Required scope: {scope}
Error message
Not enough permissions. Required scope: {scope} What it means
HTTP 403 raised by the scope_checker returned from require_scopes when the verified token's 'scopes' list is missing at least one required scope. Authentication already succeeded (verify_token ran via Depends); this is purely an authorization failure, which is why the status is 403 rather than 401.
Source
Thrown at plans/phase2-architecture/api-architecture.md:1599
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_scopes(self, required_scopes: List[str]):
"""Decorator to require specific scopes"""
async def scope_checker(token_data: dict = Depends(self.verify_token)):
user_scopes = token_data.get('scopes', [])
for scope in required_scopes:
if scope not in user_scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not enough permissions. Required scope: {scope}"
)
return token_data
return scope_checker
# API Scopes
class APIScopes:
# Read scopes
POSE_READ = "pose:read"
ANALYTICS_READ = "analytics:read"
SYSTEM_READ = "system:read"
# Write scopes
CONFIG_WRITE = "config:write"
ZONE_WRITE = "zone:write"View on GitHub (pinned to 4685618388)
Solutions
- Request a new token that includes the exact scope named in the error detail (compare against the APIScopes constants, e.g. 'pose:read')
- Check the token's decoded 'scopes' claim to see what was actually granted
- Fix scope issuance at the /token endpoint so the user's permissions map to the canonical APIScopes strings
- If 403 is unexpected, confirm the route uses the decorator with the intended scope list (typos propagate silently into 403s)
Example fix
# before
@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_READ]))])
async def create_stream(...): ... # clients with read-only tokens get 403
# after
@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_WRITE]))])
async def create_stream(...): ... # scope now matches the write intent; issue POSE_WRITE tokens to callers Defensive patterns
Strategy: validation
Validate before calling
import jwt
unverified = jwt.decode(token, options={'verify_signature': False})
required = {'pose:read'}
missing = required - set(unverified.get('scopes', []))
if missing:
token = await request_token_with_scopes(missing) # avoid the guaranteed 403 Type guard
def token_has_scopes(token_unverified: dict, required: list[str]) -> bool:
return all(s in token_unverified.get('scopes', []) for s in required) Try / catch
try:
await client.post('/api/pose/streams', ...)
except HTTPException as e:
if e.status_code == 403:
raise PermissionError(e.detail) # surface required scope to the operator
raise Prevention
- Import scope strings from the shared APIScopes constants instead of retyping them
- Issue tokens from a permission table so users automatically get the scopes their role implies
- Check the token's decoded 'scopes' claim before calling privileged endpoints
When it happens
Trigger: Calling an endpoint decorated with @require_scopes([APIScopes.POSE_WRITE]) while the token was issued only pose:read; scope-string mismatch ('pose:read' vs 'poses:read'); token minted for a lower-privilege client used against an admin route.
Common situations: Read-only API keys used on write endpoints; scope taxonomy renamed in APIScopes but stale tokens still circulating; granting scopes at /token time from a user-permissions table that lacks the new permission.
Related errors
- Inactive user
- Admin privileges required
- Permission '{permission}' required
- Access denied to zone '{zone_id}'
- Access denied to router '{router_id}'
AI-assisted analysis of ruvnet/RuView@4685618388 (2026-08-16).
Data as JSON: /api/errors/842a4a5d75bbe04b.
Report an issue: GitHub.