tiangolo/fastapi · error · HTTPException
Not enough permissions
Error message
Not enough permissions
What it means
The OAuth2 scope-enforcement gate in tutorial005. get_current_user receives SecurityScopes and, after decoding the JWT, iterates security_scopes.scopes: if any required scope is absent from the token's space-delimited 'scope' claim, it raises HTTP 401 with WWW-Authenticate: Bearer scope="<required>" so the client knows which scope is missing. Required scopes come from Security(get_current_user, scopes=[...]) on each route (e.g. read_own_items requires 'items').
Solutions
- At POST /token, request the scope the route needs: send form field scope=items (or 'me items' for both).
- Re-login to mint a fresh JWT that contains the required scope.
- Verify the scope string is space-delimited and the names exactly match the scopes declared on OAuth2PasswordBearer (here 'me' and 'items').
Example fix
// before curl -X POST /token -d 'username=johndoe&password=...&scope=me' curl /users/me/items/ # 401 Not enough permissions // after curl -X POST /token -d 'username=johndoe&password=...&scope=me items' curl /users/me/items/ # 200
Defensive patterns
Strategy: validation
Validate before calling
# Decode the JWT locally and confirm the required scope before calling
import jwt
REQUIRED = {"items"}
def has_required_scopes(token: str, required: set[str]) -> bool:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
token_scopes = set(payload.get("scope", "").split())
return required.issubset(token_scopes) Type guard
from typing import TypeGuard
def token_has_scopes(token: str, need: set[str]) -> TypeGuard[str]:
import jwt
p = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return need.issubset(set(p.get("scope", "").split())) Try / catch
import httpx
try:
r = httpx.get("/users/me/items/", headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
www = e.response.headers.get("www-authenticate", "")
# parse scope="..." and re-auth requesting those scopes
need = parse_required_scopes(www)
token = login_with_scopes(need) Prevention
- Request every scope you might need upfront at POST /token via the 'scope' form field.
- Use space-delimited scope strings matching the names declared on OAuth2PasswordBearer.
- Decode the JWT locally before calls to fail fast on missing scopes.
When it happens
Trigger: Call GET /users/me/items/ (requires scope 'items') holding a token whose 'scope' claim is only 'me' — because at POST /token the form's scope field requested only 'me'. Any route guarded by Security(..., scopes=['X']) called without X in the token trips line 135.
Common situations: Client requested the wrong scopes at token issuance; scope naming mismatch ('read:items' vs 'items'); token minted before scopes were introduced; the scope field sent as JSON instead of a form field on /token.
Related errors
AI-assisted analysis of tiangolo/fastapi@3e8d1526d8 (2026-08-11).
Data as JSON: /api/errors/0e5e56ac14b68581.
Report an issue: GitHub.
Appendix: source
Thrown at docs_src/security/tutorial005_an_py310.py:135
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
scope: str = payload.get("scope", "")
token_scopes = scope.split(" ")
token_data = TokenData(scopes=token_scopes, username=username)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
for scope in security_scopes.scopes:
if scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user
async def get_current_active_user(
current_user: Annotated[User, Security(get_current_user, scopes=["me"])],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],View on GitHub (pinned to 3e8d1526d8)