Authdog

Access Control

Last updated Sep 3, 2026
View as Markdown

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