Skip to content

users

The default auth provider + user-management module: email/password login, OAuth/OIDC sign-in, sessions, registration, invites, password reset, email verification, role assignment, admin UI, and mailer backends. It registers a UsersAuthProvider on app.state.auth.auth_provider, which the auth module's AuthMiddleware delegates to for request.state.user. (It's one of two bundled auth providers — keycloak is the alternative; install exactly one.)

ModuleMeta

FieldValue
nameUsers
route_prefix/api/users
view_prefix/users (sign-in, self-service)
admin_view_prefix/admin/users (management CRUD)
depends_on["Auth"]

Auth flow

The module is built on fastapi-users for password hashing, registration, password reset, and verification. On top of that it layers:

  • A signed session cookie (sm_auth by default) — the auth module's AuthMiddleware calls UsersAuthProvider.resolve_user, which reads it on every request and populates request.state.user. Bearer tokens (Authorization: Bearer <token>) resolve against users_access_token.
  • OAuth / OIDC sign-in (Google, GitHub, Microsoft/Entra ID, and any generic OIDC provider) — see OAuth providers.
  • An invite flow — admins generate an invite link; the recipient sets their password via POST /api/users/auth/accept-invite.
  • LoginRateLimiter — N failures within a window triggers a cooldown per email::ip key.
  • ThroughputLimiter — per-IP rate limit across register / forgot-password / accept-invite / verify-token endpoints.

Auth endpoints

Method + pathBodyNotes
POST /api/users/auth/loginOAuth2PasswordRequestFormsets sm_auth cookie + session["user_id"]; rate-limited per email
POST /api/users/auth/registerUserCreategated by users.allow_signup; rate-limited
POST /api/users/auth/forgot-passwordPasswordResetrate-limited
POST /api/users/auth/reset-password{token, password}
POST /api/users/auth/request-verify-tokenRequestVerifyTokenrate-limited
POST /api/users/auth/verifyVerifyRequest
POST /api/users/auth/accept-inviteAcceptInviteRequestsets password + signs the user in
POST /api/users/auth/demo/{role}one-click sign-in as a shared demo account (roleadmin, user); 404 unless that account is configured; rate-limited per role — see Demo mode
POST /api/users/auth/tokenTokenRequest (email + password)bearer login for mobile / API clients → {access_token, refresh_token, token_type, expires_in}; 401 for external/SSO users
POST /api/users/auth/token/refreshRefreshRequest (refresh_token)rotates a refresh token into a new pair (old one revoked)
DELETE /api/users/auth/tokenRefreshRequest (refresh_token)revokes a refresh token (idempotent)
GET /api/users/auth/{provider}/loginOAuth: redirect to the IdP (provider ∈ configured set)
GET /api/users/auth/{provider}/callback?code=&state=OAuth: find-or-create user, set cookie, 303 to login_redirect_url

Self-service endpoints

Method + pathBody / responsePermission
GET /api/users/meUserReadactive user
PATCH /api/users/meSelfProfileUpdateUserReadactive user

Admin endpoints (users.manage)

Method + pathBody / response
GET /api/users/admin?q=&status=&role=&verified=&sort=&order=&page=&per_page=list[UserListItem]
POST /api/users/adminUserAdminCreateUserListItem (201) — active+verified user with an admin-set password
POST /api/users/admin/inviteUserInviteUserListItem (201)
PATCH /api/users/admin/{user_id}UserDetailsUpdateUserListItem — edit email + full name
DELETE /api/users/admin/{user_id}204 (hard delete; an admin cannot delete their own account → 400)
PATCH /api/users/admin/{user_id}/disableUserListItem
PATCH /api/users/admin/{user_id}/enableUserListItem
PUT /api/users/admin/{user_id}/rolesRoleAssignmentUserListItem
PATCH /api/users/admin/{user_id}/verifyUserListItem (mark verified; idempotent)
POST /api/users/admin/{user_id}/reset-password-linkPasswordResetLink (409 for external/SSO users)

POST /api/users/admin creates an active + verified user directly — no invite email, no verification flow; the admin sets the password. It returns 409 if the email is already taken and 400 for an invalid password. The matching admin UI (Create form, Edit details card, and a delete "danger zone") lives under /admin/users/ — see View routes and Inertia pages.

View routes

Public:

  • GET /users/loginUsers/Login (shows dev-account buttons in dev)
  • POST /users/logout → 303 redirect, clears cookie
  • GET /users/registerUsers/Register (404 if signup disabled)
  • GET /users/forgot-passwordUsers/ForgotPassword
  • GET /users/reset-passwordUsers/ResetPassword
  • GET /users/verifyUsers/VerifyEmail
  • GET /users/invite/acceptUsers/AcceptInvite

Authenticated:

  • GET /users/meUsers/Profile
  • PATCH /users/me → form action (redirects)

Admin (users.manage) — served from the module's admin_view_prefix, /admin/users:

  • GET /admin/users/Users/Users/Index
  • GET /admin/users/addUsers/Users/AddPeople — one form for both flows; ?mode=create|invite picks which
  • GET /admin/users/{user_id}Users/Users/Edit

The separate Create and Invite pages merged into AddPeople. Their old paths survive as 307 redirects carrying the mode: GET /admin/users/invite/admin/users/add?mode=invite, and GET /admin/users/create/admin/users/add?mode=create.

These pages moved out of /users/admin when the admin screens were gathered under /admin — the pre-move URLs still 301 for now, but link to the new ones. The /api/users/admin/* endpoints below are a separate contract and did not move.

Public contracts

python
from users.contracts.schemas import (
    UserRead,
    UserCreate,
    UserUpdate,
    UserInvite,
    UserAdminCreate,
    UserDetailsUpdate,
    UserListItem,
    RoleListItem,
    RoleAssignment,
    AcceptInviteRequest,
    PasswordResetLink,
    SelfProfileUpdate,
)
from users.contracts.events import (
    UserRegistered,
    UserInvited,
    UserCreated,
    UserDeleted,
    UserDisabled,
    RoleAssigned,
)
ClassPurpose
UserReadid, email, is_active, is_superuser, is_verified, is_external, full_name, tenant_id, disabled_at, last_login_at.
UserAdminCreateAdmin create-user input: email, password, full_name, role_names.
UserDetailsUpdateAdmin edit input: email, full_name.
UserListItemAdmin list row; adds is_external, created_at, roles.
RoleListItemid, name, description, user_count.
UserRegistered, UserInvited, UserCreated, UserDeleted, UserDisabled, RoleAssignedEvents — see Events.

Models

User (table users_user)

ColumnTypeNotes
idUUIDPK
emailstrunique, indexed; functional index on lower(email)
hashed_passwordstr | Nonenullable — external (SSO) users have no local password (see External / SSO users)
is_active / is_superuser / is_verifiedbool
is_externalboolTrue for users provisioned via an external IdP; default False (server_default false)
full_namestr | None
tenant_idstr | Noneindexed; only set when multi-tenant
disabled_atdatetime | None
last_login_atdatetime | Noneindexed
rolesrelationship → Role via UserRoleeagerly loaded by AuthMiddleware

Role (table users_role) — id, name (unique, indexed), description.

UserRole (table users_user_role) — composite-PK join table with assigned_at, assigned_by. The (user_id, role_id) PK is user-id-first; a separate index covers reverse lookups by role_id.

UserAccessToken (table users_access_token) — bearer API tokens; resolved by UsersAuthProvider on Authorization: Bearer requests (sessions remain the primary browser auth).

OAuthAccount (table users_oauth_account) — linked OAuth/OIDC accounts: oauth_name, account_id, account_email, access_token, refresh_token, expires_at.

RefreshToken (table users_refresh_token) — opaque refresh tokens for the bearer flow: token (PK), user_id, created_at, expires_at, revoked_at.

Two pre-seeded roles get fixed UUIDs so other modules can reference them safely:

RoleUUIDDefault permissions
admin00000000-0000-0000-0000-000000000001implicitly all (admin bypass in RequiresPermission)
user00000000-0000-0000-0000-000000000002users.self.profile, file_storage.{upload,download,delete}

Settings

DB-backed via register_module_settings. Two values are read only from the env at module import time because they bootstrap token signing before the DB is reachable:

Env varDefaultPurpose
SM_USERS_RESET_PASSWORD_TOKEN_SECRETdev-reset-token-secret-change-mepassword-reset token signing
SM_USERS_VERIFICATION_TOKEN_SECRETdev-verify-token-secret-change-meemail-verify token signing

Both must be replaced with non-placeholder values in production — the boot-time check refuses to start otherwise.

Everything else is DB-backed (initial values are pydantic defaults; edit under Users at /admin/settings/):

FieldDefault
allow_signupFalse
require_verificationTrue
login_redirect_url"/dashboard/" (if the Dashboard module isn't installed, auto-falls back to the first other module that exposes view routes, or / only as a last resort)
reset_password_token_lifetime_seconds3600
verification_token_lifetime_seconds604_800 (7 days)
bearer_token_lifetime_seconds900 (15 min)
refresh_token_lifetime_seconds2_592_000 (30 days)
cookie_name"sm_auth"
cookie_max_age_seconds1_209_600 (14 days)
cookie_secureTrue (flipped to False in dev at startup)
cookie_samesite"lax"
mailer"console" (or "smtp")
base_url"http://localhost:8000"
smtp_host / smtp_port / smtp_username / smtp_password / smtp_from / smtp_tlsSMTP config when mailer="smtp"
login_rate_limit_failures5
login_rate_limit_window_seconds300
login_rate_limit_cooldown_seconds900
auth_rate_limit_attempts10
auth_rate_limit_window_seconds300
bootstrap_email, bootstrap_password, bootstrap_user_email, bootstrap_user_password"" — see Bootstrap
demo_modeFalse — see Demo mode
demo_admin_email"demo-admin@example.com" — blank to stop offering the admin demo
demo_user_email"demo-user@example.com" — blank to stop offering the standard-user demo
demo_admin_password / demo_user_password"" (a random one is generated, so the account is reachable only through its button)
demo_read_onlyTrue
oauth_google_client_id / oauth_google_client_secret"" — Google OAuth
oauth_github_client_id / oauth_github_client_secret"" — GitHub OAuth
oauth_microsoft_client_id / oauth_microsoft_client_secret / oauth_microsoft_tenant"" / "" / "common" — Microsoft (Entra ID)
oauth_oidc_client_id / oauth_oidc_client_secret / oauth_oidc_discovery_url / oauth_oidc_display_name"" / "" / "" / "OIDC" — generic OIDC

Permissions

CodePurpose
users.manageadmin: list / invite / disable / role-assign
users.self.profileedit own profile (granted to user role)

users.manage also unlocks account names in the audit log's entity column: the module registers its audit link with label_permission="users.manage", so a reader holding only audit_log.view sees the id of an edited account rather than its display name or email. See audit_log § Entity and actor names.

LabelURLIconSectionGroupOrderRoles
Users/admin/users/usersADMIN_SIDEBARAccess100["admin"]
Profile/users/meuserUSER_DROPDOWN990logged-in
Logout/users/logout (POST)log-outUSER_DROPDOWN999logged-in

Events

EventFieldsFired
UserRegistereduser_id, emailon signup
UserInviteduser_id, email, invited_byon admin invite
UserCreateduser_id, email, created_byon admin create (POST /api/users/admin)
UserDeleteduser_idon admin delete
UserDisableduser_idon admin disable
RoleAssigneduser_id, role_nameonce per role on PUT /admin/{user_id}/roles

CLI

  • smpy users create-admin --email <e> --password <p> [--full-name <name>] [--force] — creates (or, with --force, updates) an admin user. Idempotent: re-running with the same email is a no-op without --force.
bash
uv run smpy users create-admin --email admin@example.com --password changeme

Programmatically:

python
from users.bootstrap import create_admin

result = await create_admin(db, email="admin@example.com", password="...")
# result.user, result.created -> bool

Bootstrap (the first admin)

Two paths to seed the first admin:

  1. CLIsmpy users create-admin ....
  2. Env vars — set SM_USERS_BOOTSTRAP_EMAIL + SM_USERS_BOOTSTRAP_PASSWORD before first make dev. bootstrap_admin_from_env(app) runs at startup and creates the admin if the users_user table is empty. Optionally seed a non-admin too via SM_USERS_BOOTSTRAP_USER_EMAIL + SM_USERS_BOOTSTRAP_USER_PASSWORD.

Demo mode (hosting a showcase instance)

Turn demo_mode on and the sign-in card grows a button per demo account. One click signs the visitor straight in — no email, no password, no signup.

users.demo_mode         = true
users.demo_admin_email  = demo-admin@example.com
users.demo_user_email   = demo-user@example.com
users.demo_read_only    = true       # default; leave it on

Set these under Users at /admin/settings/, or seed them in the settings store before first boot. Changes apply live — the SettingsReloaded handler re-seeds the accounts and refreshes the cached ids, so there is no restart.

Two accounts, not one

An administrator and an ordinary standard user, because the two halves of the app look nothing alike. The admin surface — users, roles, settings, modules, audit history — is what the framework is for, and a visitor who only ever sees the end-user app never meets it. One who only ever sees /admin/* never meets the app you actually build for people. Offering both, and letting someone switch between them without signing out, is what makes a showcase instance answer "what is this?" in one visit.

Each is independently switchable: blank an email and that button disappears and its route starts answering 404 — the offer and the endpoint come from the same resolution, so hiding a button never leaves a live way in behind it. Blank both and demo_mode has nothing to turn on (logged, since you plainly meant something by switching it on).

What it does

  • Seeds the accounts on every boot and every settings reload (users.demo.ensure_demo_users). Idempotent, and it reconciles: the admin row carries is_superuser and the admin role, the user row carries neither, and repointing an email from one to the other demotes the row rather than leaving the old grant in place. One account failing to seed does not withdraw the other.
  • Never sends a password to the browser. Each button posts to POST /api/users/auth/demo/{role} with an empty body and the server resolves the account itself. This is the difference between demo mode and the dev quick-login buttons, which paste real credentials into the form and are therefore development-only. Leave a demo_*_password blank and that account is seeded with a random secret nobody — including you — can type; set one only if you also intend to publish the credentials (for an API demo, say).
  • Refuses writes while demo_read_only is on. DemoReadOnlyMiddleware rejects every unsafe HTTP method from either demo session with 403 {"detail": "DEMO_READ_ONLY"}, except signing out and the demo sign-in routes themselves — switching between the two accounts is the point of having two, so it must not require a sign-out. Inertia requests get the protocol's 409 + X-Inertia-Location instead, so a blocked save re-renders the page rather than throwing up an error modal.
  • Says so. DemoBanner renders a standing bar in every app shell, driven by the demo shared prop. It is per session, not per install — you, signed in to your own account on the same instance, do not see it, and your writes are not touched.

demo_read_only = false

With an admin demo configured, this hands anyone who can reach your sign-in page a writable superuser: the settings editor (including the SMTP password and OAuth client secrets), the user table, and maintenance mode. It is allowed — an instance whose database is rebuilt on a timer has a legitimate reason to want a writable demo — but it logs users.demo.writable_admin at WARNING on every settings load. Do not run it against a database you care about. Blanking demo_admin_email is the way to have a writable demo without that exposure.

Limits worth knowing

  • The accounts are shared. Two visitors exploring the same one at once see each other's state, and with demo_read_only = false they can overwrite each other. Periodically resetting the database is the only real answer.
  • demo_read_only matches on the session — the stamp a demo endpoint writes, or a session whose user_id is one of the demo accounts'. A bearer token is not covered directly, but minting one is itself a POST, so a read-only demo cannot get hold of one.
  • The guard deliberately does not consult demo_mode. Switching demo mode off withdraws the offer — it does not retract the sessions already handed out, and promoting those live cookies to writable superusers on the way out would be the opposite of what "turn the demo off" means. A stamped session stays read-only (and keeps its banner) until it signs out or lapses. To end the sessions themselves, disable the demo rows in the user editor; a reconcile will not re-enable them.
  • Signing in with real credentials (POST /api/users/auth/login) is allowed from a demo session — it is how you stop being the demo, it takes a real password, and the rate limiter still applies. Every non-demo sign-in clears the stamp, so a browser that once held a demo session is never locked out of the ordinary login.
  • Each role's endpoint has its own auth_rate_limit_* budget (the limiter keys on path + client IP), so a bot hammering one cannot lock a visitor out of the other. Each click mints a session row, so a demo instance under real traffic may want the budget raised.
  • The demo admin satisfies the first-run setup gate — an administrator exists, so /setup never appears. Seed your own admin first (smpy users create-admin, or the SM_USERS_BOOTSTRAP_* vars), because a read-only demo session cannot complete the wizard.

Mailer backends

mailer/ ships two implementations of the Mailer protocol:

BackendWhen to useConfigured via
ConsoleMailerdev — prints invite / verify / reset links to stdoutmailer="console"
SmtpMailerprod — talks SMTPmailer="smtp" + smtp_* settings

build_mailer(settings) returns the right instance based on users.mailer.

OAuth providers

OAuth/OIDC sign-in is DB-settings-driven: a provider is enabled simply by setting its client_id + client_secret (and, for generic OIDC, a discovery URL) in the settings UI under Users at /admin/settings/. No code change or restart — register_event_handlers rebuilds the client cache (app.state.users.oauth_clients) on the SettingsReloaded event, so changes take effect live. Providers with no credentials are silently skipped.

Built-in provider keys (the {provider} URL segment):

ProviderKeyNotes
Googlegoogle
GitHubgithub
Microsoft (Entra ID)microsoftoauth_microsoft_tenant: "common" (any account), "organizations" (work/school), or a tenant GUID
Generic OIDCoidcany provider exposing a discovery URL (Keycloak, Authentik, Auth0, Zitadel, …); discovery failure logs + disables rather than breaking boot

A single dispatcher pair (/api/users/auth/{provider}/login + /callback) serves every provider; the client is resolved per request from the cache. The /callback returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through UserManager.oauth_callback (associate_by_email=True, is_verified_by_default=True); state CSRF uses the signed session cookie. Linked accounts are stored in users_oauth_account. A newly provisioned OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged.

External / SSO users

Users created through an external IdP (Google, GitHub, Microsoft/Entra ID, or generic OIDC) are provisioned with is_external=True and no local password (hashed_password is NULL). The OAuth callback's find-or-create only nulls the password + marks external for accounts it creates — an existing password account that gets linked by email keeps its password and stays non-external. External users sign in only through their IdP; roles are still assigned locally like any other user.

Every password-credential path guards against external users, server-side:

ActionBehaviour for an external user
POST /api/users/auth/login (session)401 — treated like a missing user (a dummy bcrypt verify still runs, so timing doesn't leak that the account is SSO-only)
POST /api/users/auth/token (bearer)401 — same guard as session login
POST /api/users/auth/forgot-password200 but a silent no-op — preserves anti-enumeration
POST /api/users/admin/{user_id}/reset-password-link409 — raises ExternalUserNoPasswordError; there is no password to reset

In the admin UI, the user list and edit page show an "External · SSO" badge, and the password-reset action is hidden with an explanation. Disable / enable, role assignment, and delete work the same as for any other user.

UsersAuthProvider

UsersAuthProvider (registered on app.state.auth.auth_provider in register_settings) is the AuthProvider the auth module's AuthMiddleware delegates to. On each request resolve_user:

  • For a Bearer token, looks the token up in users_access_token and returns the active user.
  • Otherwise reads session["user_id"], returns the cached context from session["user_ctx"] on a hit, else loads the User row with eagerly-loaded roles, builds a UserContext, caches it in session["user_ctx"], and clears stale session keys when the user is missing/disabled.

get_login_url()/users/login, get_logout_url()/users/logout. The provider's get_public_paths() declares these prefixes anonymous (in addition to the framework defaults /health, /static/, /api/docs, /api/redoc, /openapi.json, /i18n/, /):

/users/login, /users/register, /users/forgot-password, /users/reset-password, /users/verify, /users/invite/accept, /api/users/auth/, /api/users/register.

Anything else without a session redirects to /users/login (or returns 401 JSON for /api/* / bearer requests).

Roles cache

roles_cache.py keeps an in-memory list of RoleSummary(id, name) on app.state.users.roles_cache. Refreshed at startup and on demand via refresh_roles_cache(app). Used by the admin UI to render role pickers without hitting the DB on every request.

Inertia pages

Auth flow:

  • Users/Login.tsx, Users/Register.tsx, Users/ForgotPassword.tsx, Users/ResetPassword.tsx, Users/VerifyEmail.tsx, Users/AcceptInvite.tsx, Users/Profile.tsx.

Admin:

  • Users/Users/Index.tsx (list + Create button), Users/Users/Invite.tsx, Users/Users/Create.tsx, Users/Users/Edit.tsx.

Components (under pages/Users/components/):

  • AccountStatusCard.tsx — status block, including the External · SSO badge and the hidden-password-reset explanation for external users.
  • DetailsCard.tsx — edit email + full name.
  • DangerZone.tsx — delete-user action with confirmation.

Notes

  • cookie_secure is automatically flipped to False in dev (SM_ENVIRONMENT=development) so login works over plain HTTP. Don't override it in non-dev environments.
  • LoginRateLimiter keys login attempts by lowercased-email::client-ip. Counters live in-process (no Redis), so a multi-worker deployment has independent counters per worker — swap for a Redis-backed store when running more than one worker.
  • The functional lower(email) index makes case-insensitive lookups fast on Postgres; on SQLite the lookup falls back to LOWER(email) = ? which still uses the regular index.

Released under the MIT License.