Once requests are authenticated, the next step is authorization: deciding whether the authenticated user is allowed to perform the action. Authdog resolves who the user is; you decide what they can do based on the roles and permissions on the user object. This guide builds a small, reusable access-control layer for FastAPI on top of authdog.require_auth.
Where roles and permissions come from
require_auth returns the user object from the identity provider's userinfo response, roles and permissions travel on it:
{
"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
A dependency factory takes the required role and returns a FastAPI dependency, depending on require_auth first so authentication is always enforced:
import os
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):
async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
if not set(user.get("roles", [])).intersection(roles):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient role")
return user
return _dependency@app.get("/admin", dependencies=[Depends(require_role("admin"))])
async def admin_dashboard():
return {"ok": True}Permission-based checks
Same factory shape, checking a permission instead of a role:
def require_permission(*required: str):
async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
missing = set(required) - set(user.get("permissions", []))
if missing:
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Missing: {', '.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)
if not (post.author_id == user.get("id") or "admin" in user.get("roles", [])):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your post")
return await db.update_post(post_id, ...)Choosing status codes
401 Unauthorized means no valid session, raised for you by require_auth. 403 Forbidden means authenticated but not allowed, raised by your role or 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.