Skip to content

Migration Guide

Litestar 2.24: explicit dependency annotations

litestar-auth now requires Litestar >=2.24.0. Litestar 2.24 deprecates inferred dependencies — handler parameters that are resolved purely by matching a registered dependency key by name. Every such parameter must carry an explicit NamedDependency[...] annotation, or Litestar emits a LitestarDeprecationWarning (and stops injecting the value entirely in Litestar 3.0).

All bundled controllers and the plugin's dependency providers are already migrated. If your application or extension defines handlers that consume litestar-auth dependencies (litestar_auth_user_manager, litestar_auth_backends, litestar_auth_organization_store, litestar_auth_permissions), annotate them with the typed aliases exported from litestar_auth.extensions:

from litestar import get
from litestar_auth.extensions import UserManagerDependency


@get("/admin/probe")
async def probe(litestar_auth_user_manager: UserManagerDependency) -> dict[str, bool]:
    return {"has_manager": litestar_auth_user_manager is not None}

For the request-scoped database session (default key db_session, configurable via db_session_dependency_key), annotate with NamedDependency directly:

from litestar import get
from litestar.di import NamedDependency
from sqlalchemy.ext.asyncio import AsyncSession

_DbSessionDep = NamedDependency[AsyncSession]


@get("/admin/session-probe")
async def probe(db_session: _DbSessionDep) -> dict[str, bool]:
    return {"in_transaction": db_session.in_transaction()}

User hard-delete cascade

Bundled user-owned auth tables declare ON DELETE CASCADE on foreign keys that point at the user table. BaseUserManager.delete() also invalidates configured token strategies and deletes SQL-backed API keys before removing the user row, so hard delete removes per-user secrets and session artifacts instead of leaving orphan rows.

For deployments using the bundled table names, migrate existing constraints by dropping each current foreign key and recreating it with ON DELETE CASCADE:

from alembic import op


def upgrade() -> None:
    for table_name in ("access_token", "refresh_token", "api_key", "oauth_account", "user_role"):
        op.drop_constraint(f"fk_{table_name}_user_id_user", table_name, type_="foreignkey")
        op.create_foreign_key(
            f"fk_{table_name}_user_id_user",
            table_name,
            "user",
            ["user_id"],
            ["id"],
            ondelete="CASCADE",
        )

Adjust constraint names for your database; SQLAlchemy/Alembic naming conventions or database-generated names may differ. For PostgreSQL tables with existing traffic, use your normal low-lock migration pattern for foreign keys: add the replacement constraint as NOT VALID, validate it separately, and drop the old constraint in a controlled migration window.

SQLite only enforces cascades when PRAGMA foreign_keys=ON is set on every connection. The library's tests enable that pragma for SQLite engines; application-owned SQLite deployments should do the same through a SQLAlchemy connect event.

Redis opaque tokens and Redis TOTP step-up markers written by current releases are indexed per user and are removed by hard delete through invalidate_all_tokens(). Older unindexed Redis token keys cannot be discovered by user id without scanning and remain TTL-bound; let them expire naturally or run an application-owned cleanup if your deployment previously wrote unindexed keys.

redis-py 8 (litestar-auth[redis])

The optional Redis extra now requires redis-py 8.x (redis>=8.0.0,<9.0.0). redis-py 7.x is no longer accepted by the extra pin; upgrade the dependency (or your lockfile) before installing litestar-auth[redis] on a new environment.

What changed in this library

  • All built-in Redis stores and strategies write expiring keys with SET ... EX= (and conditional writes with SET ... NX PX=). They do not call deprecated commands such as SETEX or PSETEX.
  • RedisAuthClientProtocol / RedisExpiringValueStoreClient expect set(name, value, ex=seconds) instead of setex(name, seconds, value). Update custom client wrappers or test doubles that implemented the old protocol shape.
  • The Redis rate limiter's retry-after Lua accepts both flat and nested ZRANGE ... WITHSCORES result shapes so behavior stays correct under redis-py 8 and fakeredis.

Application-owned Redis clients

litestar-auth does not construct your production redis.asyncio.Redis instance. When you create the client your app passes into RedisAuthPreset, token strategies, or individual stores:

  • Prefer await client.set(key, value, ex=ttl_seconds) (or px= for millisecond TTL) for new code. Avoid setex / psetex in application layers that share the same client — redis-py 8 emits DeprecationWarning for those entry points.
  • redis-py 8 uses RESP3 on the wire by default but keeps legacy RESP2-shaped Python values unless you pass legacy_responses=False. The library's protocols and tests assume the default legacy response shapes (bytes members from sorted sets, get returning bytes | None, and so on). Only opt into unified responses when you have audited every Redis call site.
  • Optional: set protocol=3 explicitly on the client if you want the wire protocol choice visible in configuration; it is not required for litestar-auth's default integration path.

Example client construction (application code):

from redis.asyncio import Redis

redis_client = Redis.from_url(
    "redis://localhost:6379/0",
    decode_responses=False,
)

Wire the same client into RedisAuthPreset(redis=redis_client) or the lower-level stores documented in Configuration.

Verification

After upgrading, reinstall/sync dependencies and run your Redis-backed auth tests. This repository uses pytest filterwarnings = ["error"] in pyproject.toml, so DeprecationWarning from redis-py (including setex / psetex in application code on a shared client) fails CI like any other warning.

DB refresh-token reuse detection

DB-backed refresh-token rotation records consumed refresh-token digests so a replayed refresh token revokes the entire refresh-session chain instead of looking like an ordinary missing token. Existing deployments using the bundled refresh_token table must add the separate indexed lookup table for consumed digests:

CREATE TABLE refresh_token_consumed_digest (
    token_digest VARCHAR(255) NOT NULL PRIMARY KEY,
    session_id VARCHAR(36) NOT NULL,
    consumed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX ix_refresh_token_consumed_digest_session_id
    ON refresh_token_consumed_digest (session_id);

SQLAlchemy metadata equivalent for a hand-written migration:

import sqlalchemy as sa
from alembic import op


def upgrade() -> None:
    op.create_table(
        "refresh_token_consumed_digest",
        sa.Column("token_digest", sa.String(length=255), primary_key=True),
        sa.Column("session_id", sa.String(length=36), nullable=False),
        sa.Column("consumed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
    )
    op.create_index(
        "ix_refresh_token_consumed_digest_session_id",
        "refresh_token_consumed_digest",
        ["session_id"],
    )

If your deployment is upgrading from a version that still had the legacy refresh_token.consumed_token_digests JSON array, run the data backfill after the lookup table exists and before serving traffic with code that removes the JSON column. Copy those values into refresh_token_consumed_digest using the row's session_id. PostgreSQL deployments can use:

INSERT INTO refresh_token_consumed_digest (token_digest, session_id, consumed_at)
SELECT legacy.token_digest, rt.session_id, CURRENT_TIMESTAMP
FROM refresh_token AS rt
CROSS JOIN LATERAL jsonb_array_elements_text(rt.consumed_token_digests::jsonb) AS legacy(token_digest)
WHERE rt.consumed_token_digests IS NOT NULL
ON CONFLICT (token_digest) DO NOTHING;

For other SQL dialects, use the dialect's JSON-array table function to unnest refresh_token.consumed_token_digests into one (token_digest, session_id) row per digest before the application starts. SQLite uses json_each; MySQL and MariaDB use JSON_TABLE.

Alembic data-migration variant for PostgreSQL:

import sqlalchemy as sa
from alembic import op


def upgrade() -> None:
    op.execute(
        sa.text(
            """
            INSERT INTO refresh_token_consumed_digest (token_digest, session_id, consumed_at)
            SELECT legacy.token_digest, rt.session_id, CURRENT_TIMESTAMP
            FROM refresh_token AS rt
            CROSS JOIN LATERAL jsonb_array_elements_text(rt.consumed_token_digests::jsonb) AS legacy(token_digest)
            WHERE rt.consumed_token_digests IS NOT NULL
            ON CONFLICT (token_digest) DO NOTHING
            """,
        ),
    )

Skipping this backfill creates a transition-window downgrade: a refresh token consumed before the upgrade and replayed after the upgrade will not be found in the indexed lookup table, so the replay will be rejected as an ordinary missing token instead of revoking the compromised session chain. The window lasts until those legacy refresh sessions expire or are explicitly revoked.

After the backfill succeeds, drop the legacy JSON column before or in the same deployment that serves the new code:

ALTER TABLE refresh_token DROP COLUMN consumed_token_digests;

Alembic variant:

from alembic import op


def upgrade() -> None:
    op.drop_column("refresh_token", "consumed_token_digests")

New refresh rotations write each consumed digest only to refresh_token_consumed_digest for indexed replay lookup; do not store raw refresh tokens in that table. Custom refresh-token models passed to DatabaseTokenModels must expose mapped session_id, last_used_at, and client_metadata attributes. The recommended path is to compose RefreshTokenMixin, which includes those columns.

API-key persistence table

API-key storage uses a dedicated api_key table. Import the bundled model from litestar_auth.models or litestar_auth.models.api_key; do not import ORM models from the package root or litestar_auth.db. The SQLAlchemy store lives at litestar_auth.db.sqlalchemy.SQLAlchemyApiKeyStore, while the structural store protocol is available as litestar_auth.db.BaseApiKeyStore.

Minimum migration shape for deployments using the bundled model:

CREATE TABLE api_key (
    id UUID PRIMARY KEY,
    key_id VARCHAR(64) NOT NULL,
    user_id UUID NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
    hashed_secret BYTEA NOT NULL,
    encrypted_secret BYTEA NULL,
    name VARCHAR(255) NOT NULL,
    scopes JSON NOT NULL,
    prefix_env VARCHAR(32) NOT NULL,
    signing_required BOOLEAN NOT NULL,
    expires_at TIMESTAMP WITH TIME ZONE NULL,
    last_used_at TIMESTAMP WITH TIME ZONE NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    revoked_at TIMESTAMP WITH TIME ZONE NULL,
    created_via VARCHAR(64) NOT NULL,
    client_metadata JSON NULL
);

CREATE UNIQUE INDEX ix_api_key_key_id ON api_key (key_id);
CREATE INDEX ix_api_key_user_id ON api_key (user_id);

SQLAlchemy metadata equivalent for a hand-written migration:

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID


def upgrade() -> None:
    op.create_table(
        "api_key",
        sa.Column("id", UUID(as_uuid=True), primary_key=True),
        sa.Column("key_id", sa.String(length=64), nullable=False),
        sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False),
        sa.Column("hashed_secret", sa.LargeBinary(), nullable=False),
        sa.Column("encrypted_secret", sa.LargeBinary(), nullable=True),
        sa.Column("name", sa.String(length=255), nullable=False),
        sa.Column("scopes", JSONB(), nullable=False),
        sa.Column("prefix_env", sa.String(length=32), nullable=False),
        sa.Column("signing_required", sa.Boolean(), nullable=False),
        sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
        sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("created_via", sa.String(length=64), nullable=False),
        sa.Column("client_metadata", JSONB(), nullable=True),
    )
    op.create_index("ix_api_key_key_id", "api_key", ["key_id"], unique=True)
    op.create_index("ix_api_key_user_id", "api_key", ["user_id"])

key_id is the public lookup identifier and must stay unique. hashed_secret stores only the keyed secret digest as bytes; raw API-key secrets are not persisted. encrypted_secret is nullable and reserved for signing-mode keys, so non-signing rows should leave it NULL. Signing-mode rows store fernet:v1:<keyring-key-id>:<ciphertext> bytes encrypted with api_keys.secret_encryption_keyring; existing bearer keys cannot be upgraded to signing mode because their raw secret was intentionally never persisted. Create replacement signing-required keys and revoke the old bearer keys during migration. client_metadata must use the same bounded shape as refresh-session metadata: 1-64 character keys and 1-255 character string values.

API-key signing-secret Fernet rotation

Deployments that enable request signing must treat api_keys.secret_encryption_keyring as an operator-rotated Fernet keyring. Rotation is a staged data migration, not an automatic library service:

  1. Add the new Fernet key id to the keyring while keeping the old id configured.
  2. Deploy the same key map with active_key_id changed to the new id.
  3. Scan API-key rows where signing_required = true and encrypted_secret IS NOT NULL.
  4. For each candidate, call BaseUserManager.api_key_signing_secret_requires_reencrypt(row) and then await BaseUserManager.reencrypt_api_key_signing_secret(row_or_key_id) when it returns True.
  5. Repeat the scan and remove the retired key id only after no signing-required row still requires re-encryption.

The helper accepts a loaded row or a public key_id; it rejects raw bearer credentials and never returns plaintext signing secrets. Bearer rows, missing encrypted_secret values, malformed Fernet envelopes, unknown key ids, and lost replacement rows are fail-closed migration errors. Resolve them explicitly instead of skipping them in a bulk job.

The library does not add built-in batching, advisory locks, audit-log storage, per-key audit tables, service-account-only keys, IP allowlists, or mTLS binding for this migration. Keep those concerns in application-owned migration and observability tooling when your deployment needs them.

Argon2-only default password helper

The library default password-helper policy is Argon2-only. PasswordHelper.from_defaults(), bare PasswordHelper(), BaseUserManager(..., password_helper=None), and LitestarAuthConfig.resolve_password_helper() all use that default.

Unsupported stored password hashes fail closed under that policy: verification returns False and verify_and_update() does not emit a replacement hash for an unsupported stored value.

Before upgrading a deployment that still depends on unsupported stored password hashes:

  1. Re-hash or reset those credentials out of band while the previous release is still serving traffic.
  2. Confirm the persisted hashes match your intended Argon2 policy.
  3. Deploy the new release only after those credentials no longer depend on unsupported formats.

Self-service password rotation endpoint

Self-service profile updates and password rotation are separate contracts. UserUpdate no longer includes password, and requests that try to set password on the self-service profile update path are rejected with REQUEST_BODY_INVALID.

Update clients that let authenticated users change their own password to call POST /users/me/change-password with ChangePasswordRequest:

{
  "current_password": "current-password",
  "new_password": "new-secure-password"
}

Wrong current passwords return the login-shaped LOGIN_BAD_CREDENTIALS contract. Invalid replacement passwords return UPDATE_USER_INVALID_PASSWORD. Admin-initiated password rotation continues through AdminUserUpdate on the privileged users update path.

Self-service UserUpdate is limited to email changes

The built-in self-service profile-update schema no longer accepts is_active, is_verified, or roles. Privileged fields live exclusively on AdminUserUpdate for the privileged PATCH /users/{id} route, and self-service requests that include them are rejected at msgspec decode (forbid_unknown_fields=True) before the controller's runtime deny-list ever runs. This closes a defense-in-depth gap where a regression in the runtime deny-list could silently turn self-update into a privilege change.

Update clients accordingly:

  • Self-service PATCH /users/me accepts { "email": "new@example.com", "current_password": "current-password" } for email changes. Missing or wrong current_password returns LOGIN_BAD_CREDENTIALS. Send privileged updates through admin PATCH /users/{user_id} with AdminUserUpdate instead.
  • Programmatic callers that constructed UserUpdate(is_active=...), UserUpdate(roles=...), or similar must switch to AdminUserUpdate(...) plus manager.update(..., allow_privileged=True).
  • The library's bundled soft-delete path on the privileged DELETE /users/{user_id} route was migrated to AdminUserUpdate(is_active=False) in this release; no application changes needed for the built-in users controller.

If you previously customised user_update_schema=... to add app-specific safe fields, declare those same names in the manager's updatable_fields allowlist. The runtime _build_safe_self_update deny-list still rejects the privileged field names as defense-in-depth for custom schemas, and the manager also rejects any non-privileged field that was not declared in its explicit field policy. For custom registration fields passed through direct create(..., safe=False) calls, add them to creatable_fields.

Superuser boolean to role membership

Superuser status is derived from role membership. The public is_superuser guard still exists, but it checks whether user.roles contains the configured superuser_role_name (default "superuser") instead of reading user.is_superuser.

Before upgrading a database that still has an is_superuser column, preserve the data by backfilling role membership for every true row:

  1. Ensure the role catalog contains the configured superuser role.
  2. Insert missing user_role association rows for users where user.is_superuser = true.
  3. Deploy code that no longer reads, writes, or serializes user.is_superuser.
  4. Drop the old is_superuser column after verifying those users authenticate with the expected role membership.

Example SQL shape for the default role name:

INSERT INTO role (name, description)
VALUES ('superuser', 'Superuser access')
ON CONFLICT (name) DO NOTHING;

INSERT INTO user_role (user_id, role_name)
SELECT id, 'superuser'
FROM "user"
WHERE is_superuser = true
ON CONFLICT DO NOTHING;

Adjust table names, quoting, and conflict handling for your database dialect and custom model family. Applications using a custom LitestarAuthConfig.superuser_role_name should backfill that normalized role name instead of "superuser".

Code changes to make at the same time:

  • Remove is_superuser from custom SQLAlchemy user models and DTOs.
  • Stop passing is_superuser to BaseUserManager.create(...), BaseUserManager.update(...), /auth/register, and /users/* payloads. The generated register and users request schemas reject undeclared keys during request decoding with ErrorCode.REQUEST_BODY_INVALID, so stale clients surface immediately instead of being silently accepted.
  • Grant or revoke superuser access by mutating the normalized roles collection through an admin path, seed script, migration, or the role-admin CLI/controller.

Custom password-hash column mapping

Custom SQLAlchemy user models should keep hashed_password as the runtime attribute consumed by managers, stores, and token fingerprinting.

When the only customization is the SQL column name, set UserModelMixin.auth_hashed_password_column_name = "password_hash" on the app-owned user model. Existing app models that already declare hashed_password = mapped_column(...) directly remain valid when the application intentionally owns that mapped attribute shape; no auth-layer behavior change is required either way.

Typing: UP bound narrowing and direct config construction

The typing-only API was tightened so downstream annotations describe the same runtime contracts the library already expects. Runtime behavior is unchanged, but type checkers may surface code that relied on broad Any-based bounds, helper-based config construction, manual generic parameters, or plain str dependency keys.

LitestarAuthConfig.create() to direct construction

Construct LitestarAuthConfig directly. The dataclass owns the full public configuration surface without separate wrapper helpers.

Before:

from uuid import UUID

from litestar_auth import LitestarAuthConfig

config = LitestarAuthConfig.create(
    user_model=User,
    user_manager_class=UserManager,
    session_maker=session_maker,
)

After:

from uuid import UUID

from litestar_auth import LitestarAuthConfig

config = LitestarAuthConfig[User, UUID](
    user_model=User,
    user_manager_class=UserManager,
    session_maker=session_maker,
)

UP bound=UserProtocol[Any] consumer code

The library's public UP type variable is bounded to UserProtocol instead of UserProtocol[Any]. Code that mirrors the old broad bound can usually drop the Any parameter, or can bind the user and ID together with Python 3.12 generic parameter syntax when the ID type matters.

Before:

from typing import Any, TypeVar

from litestar_auth.types import UserProtocol

UP = TypeVar("UP", bound=UserProtocol[Any])


def user_id(user: UP) -> object:
    return user.id

After:

from typing import TypeVar

from litestar_auth.types import UserProtocol

UP = TypeVar("UP", bound=UserProtocol)


def user_id[ID](user: UserProtocol[ID]) -> ID:
    return user.id

Use UserProtocol as the broad runtime-checkable user bound. Use UserProtocol[ID] when the function or class needs to preserve the concrete ID type through its return values or collaborators.

TOTP user-model validation moves to startup

Apps with totp_config enabled must use a user_model that exposes the TotpUserProtocol fields: email and totp_secret. The plugin checks that contract during startup, so a misconfigured app fails before routes are mounted. Previously, the same misconfiguration could surface only after a login reached the pending-2FA branch.

TOTP recovery-code lookup index migration

TOTP recovery-code storage changed from recovery_codes_hashes: list[str] to recovery_codes: dict[str, str]. The dict maps a server-side HMAC-SHA-256 lookup digest to the Argon2 hash for that one recovery code, so verification performs one dictionary lookup and one Argon2 verify instead of checking every active hash.

Migration steps:

  1. Add a distinct CSPRNG-generated UserManagerSecurity.totp_recovery_code_lookup_secret that clears the production secret-strength gate.
  2. Run your application migration to remove or null recovery_codes_hashes and add nullable JSON recovery_codes.
  3. Deploy the library/application change.
  4. Notify TOTP users that existing recovery codes no longer work; they should authenticate with their TOTP app and regenerate codes through /auth/2fa/recovery-codes/regenerate.

Litestar 2.22+ route parameters and dependencies

Litestar 2.22 deprecates inferred and legacy default parameter styles. Litestar 2.23 deprecates params.Dependency in favor of di.NamedDependency. With filterwarnings = ["error"], handlers must declare parameters explicitly and must not call the deprecated Dependency() marker at import time.

Parameter kind Preferred style Avoid
Path FromPath[T] or Annotated[T, PathParameter()] bare user_id: int on {user_id:...} routes
Query FromQuery[T] or Annotated[T, QueryParameter(...)] bare limit: int = 10 or limit: int = QueryParameter(...)
Dependency NamedDependency[T] Annotated[T, Dependency()] or value: T = Dependency()
Legacy Parameter(query=...) QueryParameter / FromQuery Parameter(query="limit")

Application routes that inject a dependency registered under the same parameter name (for example litestar_auth_user_manager when the plugin provides that key) do not emit inferred-parameter warnings even without an explicit dependency marker. Generated plugin routes in this library use NamedDependency[...] with concrete protocol types for clarity.

Factory-built handlers with dynamic query limits (for example paginated user listing) should attach an explicit signature with Annotated[..., QueryParameter(...)], following the same pattern as OAuth callback routes in litestar_auth/controllers/_oauth_associate_routes.py.

Advanced Alchemy session setup with LitestarAuth

LitestarAuth expects a request-scoped session_maker and registers LitestarAuthConfig.db_session_dependency_key (default db_session). It also appends Advanced Alchemy's async_autocommit_handler_maker() when a builtin session factory is configured.

Two supported setups:

  1. LitestarAuth only — pass async_sessionmaker(...) (or any factory with session_maker() -> AsyncSession) to LitestarAuthConfig.session_maker.
  2. SQLAlchemyPlugin + LitestarAuth — use the same session_maker for both, keep the default db_session dependency key (Advanced Alchemy's SQLAlchemyAsyncConfig.session_dependency_key is also db_session), and add both plugins. Request scope storage uses Advanced Alchemy's _aa_connection_state namespace. After constructing SQLAlchemyAsyncConfig, call bind_auth_session_to_alchemy(alchemy_config) (or pass session_scope_key=alchemy_config.session_scope_key manually) so LitestarAuth uses the same post-init scope key as Advanced Alchemy. get_or_create_scoped_session reuses an existing scoped session when present.
from litestar_auth.plugin import LitestarAuth, LitestarAuthConfig, bind_auth_session_to_alchemy

alchemy_config = SQLAlchemyAsyncConfig(...)
auth_session = bind_auth_session_to_alchemy(alchemy_config)
auth_config = LitestarAuthConfig(
    ...,
    session_maker=auth_session.session_maker,
    session_scope_key=auth_session.session_scope_key,
)

Advanced Alchemy 1.10+ deprecates repository helpers such as list_and_count in favor of get_many_and_count. The SQLAlchemy user adapter in this library uses the new API. Advanced Alchemy 1.11 adds ChoicesFilter, a clearer-named subclass of CollectionFilter with identical IN (...) behavior, for matching a field against a set of values; role-catalog materialization and the role-admin cookbook examples use ChoicesFilter accordingly. CollectionFilter and SQLAlchemy .in_() are not deprecated — this is a vocabulary choice, not a forced migration. Advanced Alchemy 1.11 updates Litestar integration code to the parameter markers introduced in Litestar 2.23. Under filterwarnings = ["error"] the test suite narrowly ignores only the set_async_context / reset_async_context DeprecationWarning that Advanced Alchemy's own Litestar plugin still emits from provide_session (removed in Advanced Alchemy 2.0); every other advanced_alchemy deprecation — including any the library itself might introduce — is still treated as an error.

from advanced_alchemy.filters import ChoicesFilter, LimitOffset

# Prefer ChoicesFilter over CollectionFilter for IN-style membership filters:
ChoicesFilter(field_name="name", values=("admin", "billing"))

# Paginate with LimitOffset via get_many_and_count (bool is descending: False = ascending):
roles, total = await repository.get_many_and_count(
    LimitOffset(limit=limit, offset=offset),
    order_by=("name", False),
    count_with_window_function=False,
)
from advanced_alchemy.config import AsyncSessionConfig
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig, SQLAlchemyPlugin
from litestar import Litestar

from litestar_auth import LitestarAuth, LitestarAuthConfig

alchemy_config = SQLAlchemyAsyncConfig(
    connection_string="postgresql+asyncpg://...",
    session_config=AsyncSessionConfig(expire_on_commit=False),
    before_send_handler="autocommit",
)
session_maker = alchemy_config.create_session_maker()

auth_config = LitestarAuthConfig(
    user_model=User,
    user_manager_class=UserManager,
    session_maker=session_maker,
    # ... other auth settings ...
)

app = Litestar(
    route_handlers=[...],
    plugins=[SQLAlchemyPlugin(config=alchemy_config), LitestarAuth(auth_config)],
)

DbSessionDependencyKey adoption

Annotate custom DB-session dependency keys with DbSessionDependencyKey instead of plain str. This keeps application code aligned with LitestarAuthConfig.db_session_dependency_key and documents the Python identifier constraint at the call site.

Before:

from litestar_auth import LitestarAuthConfig

db_session_dependency_key: str = "db_session"

config = LitestarAuthConfig[User, UUID](
    user_model=User,
    user_manager_class=UserManager,
    session_maker=session_maker,
    db_session_dependency_key=db_session_dependency_key,
)

After:

from litestar_auth import LitestarAuthConfig
from litestar_auth.types import DbSessionDependencyKey

db_session_dependency_key: DbSessionDependencyKey = "db_session"

config = LitestarAuthConfig[User, UUID](
    user_model=User,
    user_manager_class=UserManager,
    session_maker=session_maker,
    db_session_dependency_key=db_session_dependency_key,
)

String rate-limit slot keys to AuthRateLimitSlot

These snippets use InMemoryRateLimiter only to show the slot-key migration in a small single-process/dev/test setup. For production multi-worker deployments, use RedisRateLimiter or RedisAuthPreset and declare the topology with LitestarAuthConfig.deployment_worker_count.

Before:

from litestar_auth.ratelimit import AuthRateLimitConfig, EndpointRateLimit, InMemoryRateLimiter

config = AuthRateLimitConfig.from_shared_backend(
    backend=InMemoryRateLimiter(max_attempts=5, window_seconds=60),
    endpoint_overrides={
        "totp_verify": EndpointRateLimit(
            backend=InMemoryRateLimiter(max_attempts=3, window_seconds=60),
            scope="ip",
            namespace="totp-verify",
        ),
    },
)

After:

from litestar_auth.ratelimit import (
    AuthRateLimitConfig,
    AuthRateLimitSlot,
    EndpointRateLimit,
    InMemoryRateLimiter,
    SharedRateLimitConfigOptions,
)

config = AuthRateLimitConfig.from_shared_backend(
    backend=InMemoryRateLimiter(max_attempts=5, window_seconds=60),
    options=SharedRateLimitConfigOptions(
        endpoint_overrides={
            AuthRateLimitSlot.TOTP_VERIFY: EndpointRateLimit(
                backend=InMemoryRateLimiter(max_attempts=3, window_seconds=60),
                scope="ip",
                namespace="totp-verify",
            ),
        },
    ),
)

AuthRateLimitSlot keeps override mappings typed, IDE-friendly, and aligned with the preferred public surface.