Skip to content

Custom adapter

Implement the protocols in fastauth.core.protocols to plug in your own ORM. You only implement what you use.

Minimum viable adapter: UserAdapter

UserAdapter is the only one required by every app. Implement these seven methods:

from fastauth.core.protocols import UserAdapter
from fastauth.types import UserData

class MyUserAdapter(UserAdapter):
    async def create_user(self, email, hashed_password=None, **kwargs) -> UserData: ...
    async def get_user_by_id(self, user_id) -> UserData | None: ...
    async def get_user_by_email(self, email) -> UserData | None: ...
    async def update_user(self, user_id, **kwargs) -> UserData: ...
    async def delete_user(self, user_id, soft=True) -> None: ...
    async def get_hashed_password(self, user_id) -> str | None: ...
    async def set_hashed_password(self, user_id, hashed_password) -> None: ...

UserData is a TypedDict:

class UserData(TypedDict):
    id: str
    email: str
    name: str | None
    email_verified: bool
    is_active: bool
    image: str | None

Email normalization

FastAuth normalizes every email — lowercase, strip whitespace — via fastauth.core.identity.normalize_email before passing it to your adapter. Your create_user and get_user_by_email are responsible for storing and querying that normalized form consistently. If you store mixed-case emails, lookups will silently fail.

from fastauth.core.identity import normalize_email

async def get_user_by_email(self, email):
    return await self.db.users.find_one({"email": normalize_email(email)})

When to implement which

Protocol Implement when
UserAdapter Always
TokenAdapter Always (refresh tokens default to requiring it; magic links, verification, reset, email change, lockout all need it)
SessionAdapter You want /auth/sessions endpoints
RoleAdapter You want RBAC
OAuthAccountAdapter You want OAuth
PasskeyAdapter You want passkeys

TokenAdapter contract

class TokenAdapter(Protocol):
    async def create_token(self, token: TokenData) -> TokenData: ...
    async def get_token(self, token: str, token_type: str) -> TokenData | None: ...
    async def delete_token(self, token: str) -> None: ...
    async def delete_user_tokens(self, user_id: str, token_type: str | None = None) -> None: ...
    async def consume_token(self, token: str, token_type: str) -> TokenData | None: ...

consume_token is the load-bearing one. It must be atomic: pop the row (or row-equivalent) and return it only if it existed and was unexpired. If anything else (two concurrent refresh calls, a replay) races it, only one caller gets the row. The SQL adapter uses an in-transaction delete-then-return; the memory adapter uses a dict.pop. Implement this with a row lock, conditional delete, or a single atomic DELETE … RETURNING.

TokenData:

class TokenData(TypedDict):
    token: str
    user_id: str
    token_type: str
    expires_at: datetime
    raw_data: NotRequired[dict[str, Any] | None]

For password_reset, email_change, magic_link_login_request, and refresh_jti, the token field stores the SHA-256 hex of the raw token, never the raw token. Read the existing SQL adapter (adapters/sqlalchemy/token.py) for a model.

Wiring a custom adapter

config = FastAuthConfig(
    ...,
    adapter=MyUserAdapter(),
    token_adapter=MyTokenAdapter(),
    # oauth_adapter=MyOAuthAdapter(),      # only when used
)
auth = FastAuth(config)
auth.role_adapter = MyRoleAdapter()           # only when used
auth.session_adapter = MySessionAdapter()     # only when used

Reference

Full signatures: see Protocols reference (auto-generated) or read packages/fastauth/src/fastauth/core/protocols.py.