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

  1. At POST /token, request the scope the route needs: send form field scope=items (or 'me items' for both).
  2. Re-login to mint a fresh JWT that contains the required scope.
  3. 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

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)