Extensions

Security

Our app can talk to its users now — but before it can have users at all, it needs to know who they are. The Security battery is WebFluid's answer to that: a user store with roles and permissions, argon2 password hashing, CSRF protection, two-factor auth and OAuth logins, all wrapped in ready-made FastAPI dependencies.

Enabling security

The battery keeps its users, roles and tokens in the database, so it builds on the SQLAlchemy extension — both switches have to be on. It also signs its CSRF and one-time tokens with a dedicated secret. In production a missing SECURITY_SECRET is a hard error; in debug mode it falls back to a fixed development secret and warns you about it.

app_configs/app.ini ini
[general]
SECRET_KEY = supersecret

[data]
DATABASE_URI = sqlite:///app.db

[extensions]
EXT_SQLALCHEMY = 1
EXT_SECURITY = 1

[security]
SECURITY_SECRET = another-long-secret
 

Security brings a whole set of db models (users, roles, permissions, plus the 2FA and OAuth identity tables). Just like with Babel, this is the moment to run the migration flow again (wf migrate revision app -a and wf migrate upgrade app) so the tables actually exist.

The services

Everything is reached through the shared registry in webfluid.core.ext. The security instance exposes four services:

  • security.user_service — the current user and every route guard.
  • security.hash_service — argon2 password hashing.
  • security.token_service — CSRF and signed one-time tokens.
  • security.oauth_service — social logins through authlib.

Registering and authenticating users

There is no one-size-fits-all signup endpoint — you own that flow. The battery gives you the pieces: the User model, the hasher and a pair of validators. The validators are built to plug into Pydantic, so the natural home for them is a schema — and that earns our project its fluid/schemas package, sitting right next to the models and services we've already grown:

fluid/schemas/accounts.py python
from pydantic import BaseModel, EmailStr, field_validator

from webfluid.extensions.security.utils import (
    validate_username, validate_password
)


class CreateUser(BaseModel):
    username: str
    email: EmailStr
    password: str

    # validate_username / validate_password raise on bad input, so they
    # drop straight in as field validators (validate_password even
    # attaches an .errors list with the individual reasons).
    _validate_username = field_validator("username")(validate_username)
    _validate_password = field_validator("password")(validate_password)

With the input validated, a small service turns the schema into a stored user, hashing the password on the way in:

fluid/services/accounts.py python
from webfluid.core.ext import db, security
from webfluid.extensions.security.models import User
from sqlalchemy import select

from fluid.schemas.accounts import CreateUser


async def register(data: CreateUser) -> User:
    # ahash runs argon2 on a dedicated thread pool. Hashing a password
    # costs about 40ms of pure CPU, and every one of those milliseconds
    # would otherwise be a millisecond your event loop serves nobody.
    psw_hash = await security.hash_service.ahash(data.password)

    async with db.async_executor(model=User) as e:
        user = User(data.username, data.email, psw_hash)
        await e.insert(user, flush=True)
        return user


async def authenticate(username: str, password: str) -> User | None:
    async with db.async_executor(model=User) as e:
        result = await e.exec(select(User).where(User.username == username))
        user = result.first()

    if not user or not user.psw_hash:
        return None
    if not await security.hash_service.averify(user.psw_hash, password):
        return None
    return user

hash and verify still exist for synchronous code; inside an async handler, prefer the awaitable pair. The pool is sized by SECURITY_HASHER_THREADS, which also caps how many logins can hash at the same time — that is a feature, not an oversight, since argon2 is deliberately expensive.

Logging in and out

The current user is derived from request.session["user_id"], so logging in is simply storing that id and logging out is dropping it. The session cookie is already signed by the framework, so there is nothing else to wire up. These are JSON endpoints, so they join the api_router in fluid/api next to the routes from the Mail chapter:

fluid/api/accounts.py python
from fastapi import Request
from fastapi.exceptions import HTTPException

from fluid.services.accounts import authenticate


async def login(request: Request):
    data = await request.json()
    user = await authenticate(data["username"], data["password"])
    if not user:
        raise HTTPException(status_code=401, detail="INVALID_CREDENTIALS")

    request.session["user_id"] = user.id
    return {"id": user.id, "username": user.username}


async def logout(request: Request):
    request.session.pop("user_id", None)
    return {"status": "ok"}

Knowing the current user

The user service hands you FastAPI dependencies. The plain current_user yields the logged-in User or None; require_user enforces a session (and validates CSRF on unsafe methods) and raises 401 otherwise:

fluid/api/accounts.py python
from webfluid.core.ext import security
from webfluid.extensions.security.models import User


async def me(user: User = security.user_service.require_user):
    return {"id": user.id, "username": user.username}


async def maybe(user: User | None = security.user_service.current_user):
    return {"authenticated": user is not None}
 

The user you get back is detached: the dependency closes its session and releases its database connection before handing the object over, so an authenticated request does not hold a pool slot for its whole lifetime. The columns are all there — what is gone is the session behind them, which matters for relationships. That is the next section.

Roles and permissions

Users carry Roles, roles carry Permissions and an is_admin flag. The user service turns all of that into dependencies you drop straight into your routes:

example python
from webfluid.core.ext import security

svc = security.user_service

# Attributes (no call): a fixed guard.
async def dashboard(user = svc.require_admin): ...

# Methods (called): parametrised guards.
async def write_posts(user = svc.require_permissions(["posts:write"])): ...
async def staff_area(user = svc.require_any_role(["editor", "moderator"])): ...

The full set is require_user, require_2fa, require_admin, require_roles / require_any_role and require_permissions / require_any_permission. The _any_ variants pass if the user matches at least one entry; the others require all of them.

 

The guards build on each other: require_2fa implies require_user, and require_admin as well as the role and permission guards all imply require_2fa. So a permission check also enforces authentication and, when the user has a second factor configured, that it has been verified in this session (request.session['2fa_verified']).

Every guard also has a plain-function twin ending in _fnrequire_admin_fn, require_roles_fn(roles) and so on. Those are the same resolvers without the FastAPI Depends wrapper, for the times you need to run a check somewhere that is not a route signature. And for a check on a user you already hold, the predicates are exported directly: has_2fa(user), await is_admin(user), await has_roles(user, roles) and their any variants.

Relationships do not load themselves

Here is the one thing to internalise about these models. Every relationship on User, Role and Permission is declared lazy="raise_on_sql", with exactly two exceptions: user.totp_secret and user.webauthn_credentials, which the 2FA gate reads on every gated request and which therefore have to arrive eagerly.

Everything else — user.roles, role.permissions, user.identities, user.backup_codes and every reverse side — raises the moment you touch it, instead of quietly firing a query. That is deliberate: the gates query the association tables directly and never navigate a relationship, so loading them for every authenticated request was pure waste — and role.users in particular meant hydrating every user holding a role on every admin request.

So when you do want them, say so:

fluid/services/accounts.py python
from webfluid.core.ext import db
from webfluid.extensions.security.models import User
from sqlalchemy import select
from sqlalchemy.orm import selectinload


async def roles_of(user_id: int) -> list[str]:
    async with db.async_executor(model=User) as e:
        result = await e.exec(
            select(User)
            .where(User.id == user_id)
            .options(selectinload(User.roles))
        )
        user = result.first()

    # Loaded explicitly, so it survives the closed session:
    return [role.name for role in user.roles]
 

Reading user.roles on the object a guard handed you is the most likely thing to break when you upgrade from an alpha. It does not return an empty list — it raises, on purpose, so the missing load is a visible error instead of an invisible query in a template loop.

CSRF protection

The token service ships a double-submit CSRF guard. require_user already runs it on unsafe methods, but you can also apply it on its own. Hand the browser a token with csrf_response (it sets the cookie and the session token) and echo it back in the X-CSRF-Token header on your next request:

example python
from fastapi import Request

from webfluid.core.ext import security


# GET this once to receive the csrf cookie + session token.
async def csrf(request: Request):
    return security.token_service.csrf_response(request)


# Protect a standalone route (require_user does this for you already).
async def submit(request: Request, _=security.token_service.csrf_protect):
    ...

Single-use links

The same token service also mints signed one-time tokens for the flows every app eventually needs — confirm your email, reset your password. generate_token(data, salt) packs a payload into a signed string; validate_token(token, salt) unpacks it and, for any salt other than "csrf", burns it: the token is recorded in an ExpiredToken table so a second attempt is rejected even though the signature is still perfectly valid.

example python
from webfluid.core.ext import security

svc = security.token_service

# Hand this out in a mail link:
token = svc.generate_token({"user_id": user.id}, salt="verify-email")

# On the receiving route. Raises 403 TOKEN_EXPIRED the second time
# around, and 403 INVALID_TOKEN if it was tampered with:
data = await svc.validate_token(token, salt="verify-email")

Spent tokens are swept from the table on a schedule once they are older than SECURITY_TOKEN_MAX_AGE, so the table stays small — provided EXT_SCHEDULING is on to run that job.

Two-factor, backup codes and OAuth

The models also cover the harder parts of auth: TOTPSecret for authenticator apps, WebAuthnCredential for passkeys and BackupCode for one-time recovery codes. has_2fa(user) tells you whether a user has any second factor at all. Social logins run through security.oauth_service, which wraps authlib, is configured with SECURITY_OAUTH_CLIENTS and links each external login to a user through the Identity model. The User model carries email_verified and pending_email for the address-change dance.

Role carries one field the battery never reads itself: requires_2fa. It is there for the app on top — an admin panel or an accounts Additive can look up whether any of a user's roles demand a second factor and compose that with require_user and has_2fa into a gate of its own, the same way require_2fa composes require_user internally. The framework hands you the flag and the primitives to act on it; deciding what "requires 2FA" means for your roles is policy, and policy belongs to your app.

 

These are building blocks, not a turnkey flow. The battery gives you the models, the hasher, the guards, the tokens and the OAuth plumbing, but the actual enrollment and verification routes are yours to write.

Letting machines in

One last pair, for when a route has to answer both a browser and a script. With EXT_JWT enabled, requirement_or_grant accepts either a session that satisfies the requirement or a bearer token whose permissions claim carries the named grant. requirement_and_grant demands both. The requirement is a small dictionary, so the same shape works for roles and permissions:

example python
from webfluid.core.ext import security

svc = security.user_service


# A logged-in editor, or a token carrying the "posts:write" grant:
async def publish(user = svc.requirement_or_grant(
    {"requirement": "has_any_role", "roles": ["editor", "admin"]},
    "posts:write"
)): ...


# Simply "somebody is authenticated", or a valid grant:
async def ingest(user = svc.requirement_or_grant(
    {"requirement": "is_authenticated"}, "data:ingest"
)): ...

The token is minted by the JWTManager, so its sub claim resolves back to a real User and your handler receives the same object either way. An admin always passes the requirement side, whatever it says. If you only ever want the token identity, resolve_bearer and bearer_principal give it to you without any session logic at all.

 

The resolver looks the principal up by primary key, so it reads sub as an integer. Mint your tokens with the user's id in that claim — a uuid or an email raises inside the gate's fallback branch, where nothing catches it, and the request comes back as a 500 rather than a 401.

The config values

  • SECURITY_SECRET — signs CSRF and one-time tokens. Required in production; in debug mode a fixed development secret is used and warned about.
  • SECURITY_TOKEN_MAX_AGE — lifetime of signed tokens in seconds. Default 3600.
  • SECURITY_CSRF_COOKIE_NAME / SECURITY_CSRF_COOKIE_SECURE — the CSRF cookie. Defaults csrf_token / True. Both sides of the double-submit check read the name from here, so renaming the cookie is a one-line change.
  • SECURITY_HASHER_TIME_COST / _MEMORY_COST / _PARALLELISM — argon2 tuning. Defaults 3 / 65536 / 4.
  • SECURITY_HASHER_THREADS — workers in the pool ahash / averify run on. Default 4.
  • SECURITY_PASSWORD_MIN_LENGTH / SECURITY_PASSWORD_REQUIREMENTS — the policy the validators enforce. Defaults 8 and one each of lower, upper, digits and special.
  • SECURITY_OAUTH_CLIENTS — a mapping of provider name to authlib client settings.
  • SECURITY_MODELS_DB_BIND — put the security tables on a non-default bind.
 

SECURITY_PASSWORD_REQUIREMENTS is merged, not replaced: a class you leave out of your dictionary falls back to one required character, not zero. To drop a requirement you have to say so explicitly — {"special": 0} keeps demanding a lower case, an upper case and a digit.

Continue reading

From here you can continue straight with Events.