Where roles and permissions come from
require_auth returns the user object from the identity provider's userinfo
response. Role and permission claims travel on that object โ for example:
{
"id": "usr_123",
"email": "[email protected]",
"roles": ["admin"],
"permissions": ["posts:read", "posts:write"]
}
The exact claim names depend on how your Authdog environment is configured. Adjust the accessors below to match your token's shape.
A `require_role` dependency
The pattern is a dependency factory: a function that takes the required role
and returns a FastAPI dependency. It depends on require_auth first โ so
authentication is always enforced โ then checks the claim:
from typing import Any
from fastapi import Depends, HTTPException, status
from authdog.fastapi import Authdog
authdog = Authdog(public_key=os.environ["PK_AUTHDOG"])
def require_role(*roles: str):
"""FastAPI dependency: 401 if unauthenticated, 403 if the role is missing."""
async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
user_roles = set(user.get("roles", []))
if not user_roles.intersection(roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient role",
)
return user
return _dependency
Use it on any route:
@app.get("/admin", dependencies=[Depends(require_role("admin"))])
async def admin_dashboard():
return {"ok": True}
# Or capture the user when you need it:
@app.delete("/posts/{post_id}")
async def delete_post(post_id: str, user=Depends(require_role("admin", "editor"))):
...
Permission-based checks
Fine-grained authorization checks a permission rather than a role. Same factory shape:
def require_permission(*required: str):
async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
granted = set(user.get("permissions", []))
missing = set(required) - granted
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission(s): {', '.join(sorted(missing))}",
)
return user
return _dependency
@app.post("/posts", dependencies=[Depends(require_permission("posts:write"))])
async def create_post():
...
Resource-level (ownership) checks
Role and permission gates run before the handler. Ownership checks need the resource, so do them inside the handler once you have the authenticated user:
@app.patch("/posts/{post_id}")
async def update_post(post_id: str, user=Depends(authdog.require_auth)):
post = await db.get_post(post_id)
if post is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
is_owner = post.author_id == user.get("id")
is_admin = "admin" in user.get("roles", [])
if not (is_owner or is_admin):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your post")
return await db.update_post(post_id, ...)
Choosing status codes
- 401 Unauthorized โ no valid session. Raised for you by
require_auth. - 403 Forbidden โ authenticated, but not allowed. Raised by your role / permission checks.
Keeping these distinct lets your frontend redirect to sign-in on a 401 and show an "access denied" message on a 403.
Next steps
- Basic authentication โ revisit the session, gate, and logout primitives.
- Python SDK overview โ the full framework matrix.