Skip to content

User manager

For plugin-managed apps, the authoritative wiring for user_manager_security, password_validator_factory, PasswordHelper sharing, and UserEmailField / UserPasswordField reuse lives in Configuration. This page focuses on the direct BaseUserManager API once those inputs have already been resolved.

The default plugin builder now treats user_manager_security as an end-to-end constructor contract. When that typed bundle is present, the plugin calls user_manager_class(..., password_helper=..., security=UserManagerSecurity(...), password_validator=..., backends=..., login_identifier=..., unsafe_testing=...) and folds the effective id_parser into security first. If your manager narrows or renames that BaseUserManager-style constructor surface, user_manager_factory is the explicit customization path. That changes who constructs the manager; it does not create a second implicit injection path for password_helper, password_validator, or security kwargs. Custom factories must wire those inputs themselves when they still want them.

When you instantiate BaseUserManager yourself, pass secrets and optional id_parser through the typed security=UserManagerSecurity(...) bundle only. Pair that with PasswordHelper.from_defaults() when you want the library's default Argon2-only hasher policy. Unsupported stored password hashes fail closed under that default, so rotate or reset those credentials before rollout. Use PasswordHelper(password_hash=...) only for deliberate application-owned custom pwdlib composition. Use BaseUserManagerConfig(...) when direct construction code should pass the manager settings as one typed object instead of individual keyword arguments.

Across plugin-managed and direct-manager flows, the stable account-state policy surface remains require_account_state(user, *, require_verified=False). The built-in implementation delegates to UserPolicy.require_account_state; custom managers or adapters should preserve the same callable shape and semantics when they customize account-state enforcement. The built-in ordering is inactive first, then unverified when require_verified=True.

The generated register and users controllers now require strict request schemas: the built-in UserCreate / UserUpdate DTOs use forbid_unknown_fields=True, and custom user_create_schema / user_update_schema values passed to the controller factories must do the same. Undeclared keys therefore fail request validation with ErrorCode.REQUEST_BODY_INVALID instead of being silently ignored. Assign the configured superuser role to grant elevated access.

BaseUserManager.update(...) also fails closed on privileged fields by default. Direct callers must pass allow_privileged=True when they intentionally mutate is_active, is_verified, or roles. Public self-service HTTP flows never set those fields; admin-only routes, OAuth verification bootstrap, and role-administration helpers do so explicitly.

BaseUserManager is now explicitly documented as a façade over three service entrypoints: manager.users for CRUD and password lifecycle flows, manager.tokens for verify/reset token flows, and manager.totp for TOTP secret storage. Low-level JWT helpers sit under manager.tokens.security. The convenience methods on BaseUserManager still forward to those services, so existing call sites continue to work; prefer the service properties when you are working within one subsystem directly.

The default no-op lifecycle hook implementations live on UserManagerHooks, which BaseUserManager inherits. Subclass BaseUserManager exactly as before; the mixin split only keeps the manager surface easier to navigate and document.

BaseUserManager.totp_secret_storage_posture is the stable direct-manager contract for persisted TOTP secrets. It reports the Fernet-encrypted at-rest posture; providing a Fernet key through security=UserManagerSecurity(totp_secret_keyring=FernetKeyringConfig(...)) enables encrypted storage and decryption with active-key rotation. The one-key security=UserManagerSecurity(totp_secret_key=...) shortcut remains available and is encoded under the default key id. Leaving both fields unset is valid for direct managers that do not persist TOTP secrets, but non-null TOTP secret writes and unprefixed legacy plaintext reads fail closed. When the plugin owns TOTP wiring, its validation path reads the same posture contract instead of special-casing manager instances. New persisted TOTP secret writes use the versioned Fernet-at-rest envelope fernet:v1:<key_id>:<ciphertext>. manager.totp_secret_requires_reencrypt(stored) detects whether a stored value is under a non-active configured key, and manager.reencrypt_totp_secret_for_storage(stored) rewrites a stored value with the active key while preserving None for users without TOTP enabled. Those helpers are row-level migration primitives: your job should scan persisted user rows, rewrite values that need rotation, verify no values still return True, and only then remove retired key ids from FernetKeyringConfig.keys. Legacy unversioned Fernet rows need explicit old-key migration code, and plaintext TOTP rows remain unsupported.

API-key signing-secret rotation uses the API-key config keyring instead of UserManagerSecurity. When api_keys.secret_encryption_keyring.active_key_id changes, application migration jobs should scan signing-required API-key rows with non-null encrypted_secret, call manager.api_key_signing_secret_requires_reencrypt(row), and rewrite only matching rows through await manager.reencrypt_api_key_signing_secret(row_or_key_id). These helpers are explicit row-level primitives: they do not create routes, run background sweeps, return plaintext signing secrets, or call API-key create/revoke/use lifecycle hooks. Bearer keys, missing encrypted secrets, missing stores or keyrings, malformed Fernet envelopes, and unknown key ids fail closed with manager errors.

For production, keep verification_token_secret, reset_password_token_secret, login_identifier_telemetry_secret, and every configured TOTP Fernet key distinct on UserManagerSecurity. Outside testing, BaseUserManager(...) raises ConfigurationError when one configured value is reused across those roles (as resolved from the security bundle). Distinct audiences (litestar-auth:verify and litestar-auth:reset-password) already scope the JWT flows correctly, but separate secrets still reduce blast radius and keep TOTP encryption and failed-login telemetry independent of JWT signing. login_identifier_telemetry_secret is optional; when it is omitted, failed-login logs do not include an identifier_digest. For plugin-managed apps, LitestarAuth(config) validation enforces the broader config-owned surface, including totp_config.totp_pending_secret and its litestar-auth:2fa-pending / litestar-auth:2fa-enroll controller flow when TOTP is enabled. If a custom user_manager_factory constructs BaseUserManager with a different verification/reset/TOTP secret surface, the manager applies the same fail-closed validation for the roles it actually receives.

litestar_auth.manager

User-management business logic for litestar-auth.

BaseUserManager(user_db=None, *, config=None, **options)

BaseUserManager(*, config: BaseUserManagerConfig[UP, ID])
BaseUserManager(user_db: BaseUserStore[UP, ID], **options: Unpack[BaseUserManagerOptions[UP, ID]])
BaseUserManager(*, user_db: BaseUserStore[UP, ID], **options: Unpack[BaseUserManagerOptions[UP, ID]])

Bases: UserManagerHooks[UP], _UserLifecycleManagerProtocol[UP, ID], _AccountTokensManagerProtocol[UP, ID], _TotpSecretsManagerProtocol[UP], ApiKeyManagerFacade[UP, ID], TotpManagerFacade[UP]

Coordinate user persistence, password hashing, and account tokens.

High-level flows stay available as convenience methods on the manager. Advanced integrations can use users, tokens, and totp to work against the decomposed internal services directly.

Initialize the user manager.

Parameters:

Name Type Description Default
user_db BaseUserStore[UP, ID] | None

Persistence backend used to load and update users.

None
config BaseUserManagerConfig[UP, ID] | None

User-manager configuration object. Do not combine with user_db or keyword options.

None
**options Unpack[BaseUserManagerOptions[UP, ID]]

Individual user-manager settings. Do not combine with config.

{}

Raises:

Type Description
TypeError

If neither user_db nor config is provided.

ValueError

If config is combined with user_db or keyword options.

Source code in litestar_auth/manager.py
def __init__(
    self: BaseUserManager[UP, ID],
    user_db: BaseUserStore[UP, ID] | None = None,
    *,
    config: BaseUserManagerConfig[UP, ID] | None = None,
    **options: Unpack[BaseUserManagerOptions[UP, ID]],
) -> None:
    """Initialize the user manager.

    Args:
        user_db: Persistence backend used to load and update users.
        config: User-manager configuration object. Do not combine with
            ``user_db`` or keyword options.
        **options: Individual user-manager settings. Do not combine with
            ``config``.

    Raises:
        TypeError: If neither ``user_db`` nor ``config`` is provided.
        ValueError: If ``config`` is combined with ``user_db`` or keyword options.
    """
    if config is not None:
        if user_db is not None or options:
            msg = "Pass either BaseUserManagerConfig or user_db plus keyword options, not both."
            raise ValueError(msg)
        settings = config
    else:
        if user_db is None:
            msg = "BaseUserManager requires user_db or config."
            raise TypeError(msg)
        settings = BaseUserManagerConfig(user_db=user_db, **options)

    resolved_secret_inputs = resolve_secret_inputs(
        settings.security,
        secret_factory=cast("type[_SecretValue]", _SecretValue),
        unsafe_testing=settings.unsafe_testing,
    )
    validate_secret_distinctness(resolved_secret_inputs, unsafe_testing=settings.unsafe_testing)
    self._assign_constructor_attributes(
        ConstructorAttributes(
            user_db=settings.user_db,
            oauth_account_store=settings.oauth_account_store,
            api_key_store=settings.api_key_store,
            api_key_config=settings.api_key_config,
            resolved_secret_inputs=resolved_secret_inputs,
            verification_token_lifetime=settings.verification_token_lifetime,
            reset_password_token_lifetime=settings.reset_password_token_lifetime,
            password_validator=settings.password_validator,
            reset_verification_on_email_change=settings.reset_verification_on_email_change,
            backends=settings.backends,
            login_identifier=settings.login_identifier,
            superuser_role_name=settings.superuser_role_name,
            unsafe_testing=settings.unsafe_testing,
        ),
    )
    self._build_internal_services(settings.password_helper)

account_token_secrets property

Return the resolved verify/reset secret bundle used by account-token services.

recovery_code_lookup_secret property

Return the configured TOTP recovery-code lookup HMAC key.

tokens property

Return the token service backing verify and reset flows.

totp property

Return the TOTP secret service backing storage and decryption flows.

totp_secret_storage_posture property

Return the explicit storage contract for persisted TOTP secrets.

users property

Return the lifecycle service backing CRUD and authentication flows.

authenticate(identifier, password, *, login_identifier=None) async

Return the matching user when credentials are valid.

After successful verification, if the stored hash is deprecated under the active helper policy, the password hash is upgraded to the current algorithm in the DB. If the DB update fails, login still succeeds without upgrading the hash.

Parameters:

Name Type Description Default
identifier str

Email or username string, depending on login_identifier.

required
password str

Plain-text password.

required
login_identifier LoginIdentifier | None

Lookup mode; defaults to :attr:login_identifier on this manager.

None
Source code in litestar_auth/manager.py
async def authenticate(
    self,
    identifier: str,
    password: str,
    *,
    login_identifier: LoginIdentifier | None = None,
) -> UP | None:
    """Return the matching user when credentials are valid.

    After successful verification, if the stored hash is deprecated under the active
    helper policy, the password hash is upgraded to the current algorithm in the DB.
    If the DB update fails, login still succeeds without upgrading the hash.

    Args:
        identifier: Email or username string, depending on ``login_identifier``.
        password: Plain-text password.
        login_identifier: Lookup mode; defaults to :attr:`login_identifier` on this manager.
    """
    mode = login_identifier if login_identifier is not None else self.login_identifier
    user = await self._user_lifecycle.authenticate(
        identifier,
        password,
        login_identifier=mode,
        dummy_hash=self._get_dummy_hash(),
        logger=logger,
    )
    if user is None:
        log_extra: dict[str, object] = {
            "event": "login_failed",
            "login_identifier_type": mode,
        }
        if self.login_identifier_telemetry_secret is not None:
            log_extra["identifier_digest"] = _login_identifier_digest(
                identifier,
                key=self.login_identifier_telemetry_secret.get_secret_value(),
            )
        logger.warning(
            "User login failed",
            extra=log_extra,
        )
        return None

    logger.info("User login succeeded", extra={"event": "login", "user_id": str(user.id)})
    return user

create(user_create, *, safe=True, allow_privileged=False) async

Create a new user, hashing the provided password before persistence.

Privileged fields (is_active, is_verified, roles) are silently dropped unless allow_privileged=True. With safe=True (default), any field outside {"email", "password"} is also dropped.

Returns:

Type Description
UP

The newly created user.

Source code in litestar_auth/manager.py
async def create(
    self,
    user_create: msgspec.Struct | Mapping[str, Any],
    *,
    safe: bool = True,
    allow_privileged: bool = False,
) -> UP:
    """Create a new user, hashing the provided password before persistence.

    Privileged fields (``is_active``, ``is_verified``, ``roles``) are
    silently dropped unless ``allow_privileged=True``. With ``safe=True``
    (default), any field outside ``{"email", "password"}`` is also
    dropped.

    Returns:
        The newly created user.
    """
    user = await self._user_lifecycle.create(user_create, safe=safe, allow_privileged=allow_privileged)
    logger.info("User registered", extra={"event": "register", "user_id": str(user.id)})
    return user

delete(user_id) async

Delete a user permanently and run the post-delete hook.

Source code in litestar_auth/manager.py
async def delete(self, user_id: ID) -> None:
    """Delete a user permanently and run the post-delete hook."""
    await self._user_lifecycle.delete(user_id)

forgot_password(email) async

Trigger the forgot-password flow without revealing whether a user exists.

Source code in litestar_auth/manager.py
async def forgot_password(self, email: str) -> None:
    """Trigger the forgot-password flow without revealing whether a user exists."""
    await self._account_tokens.forgot_password(email, dummy_hash=self._get_dummy_hash())

get(user_id) async

Return a user by identifier.

Returns:

Type Description
UP | None

The matching user when one exists, otherwise None.

Source code in litestar_auth/manager.py
async def get(self, user_id: ID) -> UP | None:
    """Return a user by identifier.

    Returns:
        The matching user when one exists, otherwise ``None``.
    """
    return await self._user_lifecycle.get(user_id)

list_users(*, offset, limit) async

Return paginated users and the total available count.

Source code in litestar_auth/manager.py
async def list_users(self, *, offset: int, limit: int) -> tuple[list[UP], int]:
    """Return paginated users and the total available count."""
    return await self._user_lifecycle.list_users(offset=offset, limit=limit)

request_verify_token(email) async

Generate a new verification token for an existing unverified user.

Source code in litestar_auth/manager.py
async def request_verify_token(self, email: str) -> None:
    """Generate a new verification token for an existing unverified user."""
    await self._account_tokens.request_verify_token(email)

require_account_state(user, *, require_verified=False)

Validate account-state policy for authenticated flows.

Parameters:

Name Type Description Default
user UP

User to validate.

required
require_verified bool

When True, also enforce is_verified.

False
Source code in litestar_auth/manager.py
def require_account_state(self, user: UP, *, require_verified: bool = False) -> None:  # noqa: PLR6301
    """Validate account-state policy for authenticated flows.

    Args:
        user: User to validate.
        require_verified: When ``True``, also enforce ``is_verified``.
    """
    UserPolicy.require_account_state(user, require_verified=require_verified)

reset_password(token, password) async

Reset a user's password using a signed reset token.

Returns:

Type Description
UP

The updated user instance.

Source code in litestar_auth/manager.py
async def reset_password(self, token: str, password: str) -> UP:
    """Reset a user's password using a signed reset token.

    Returns:
        The updated user instance.
    """
    return await self._account_tokens.reset_password(token, password)

update(user_update, user, *, allow_privileged=False) async

Update mutable user fields, hashing passwords when provided.

Fields with None values in user_update are treated as absent and will not overwrite existing data. To explicitly clear a nullable field, use a dedicated method (e.g. set_totp_secret(user, None)).

Privileged fields such as is_active, is_verified, and roles are rejected unless allow_privileged=True is passed explicitly.

Returns:

Type Description
UP

The updated user, or the original user when there are no changes.

Source code in litestar_auth/manager.py
async def update(
    self,
    user_update: msgspec.Struct | Mapping[str, Any],
    user: UP,
    *,
    allow_privileged: bool = False,
) -> UP:
    """Update mutable user fields, hashing passwords when provided.

    Fields with ``None`` values in *user_update* are treated as absent and
    will **not** overwrite existing data.  To explicitly clear a nullable
    field, use a dedicated method (e.g. ``set_totp_secret(user, None)``).

    Privileged fields such as ``is_active``, ``is_verified``, and ``roles``
    are rejected unless
    ``allow_privileged=True`` is passed explicitly.

    Returns:
        The updated user, or the original user when there are no changes.
    """
    return await self._user_lifecycle.update(user_update, user, allow_privileged=allow_privileged)

verify(token) async

Mark a user as verified using a signed verification token.

Returns:

Type Description
UP

The verified user instance.

Source code in litestar_auth/manager.py
async def verify(self, token: str) -> UP:
    """Mark a user as verified using a signed verification token.

    Returns:
        The verified user instance.
    """
    return await self._account_tokens.verify(token)

write_verify_token(user)

Return a signed email-verification token for a user.

Returns:

Type Description
str

A short-lived verification token.

Source code in litestar_auth/manager.py
def write_verify_token(self, user: UP) -> str:
    """Return a signed email-verification token for a user.

    Returns:
        A short-lived verification token.
    """
    return self._account_tokens.write_verify_token(user)