# 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`:

```python
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:

```python
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`:

```python
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):

```python
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](configuration/redis.md#redis-backed-auth-surface).

### 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:

```sql
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:

```python
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:

```sql
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:

```python
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:

```sql
ALTER TABLE refresh_token DROP COLUMN consumed_token_digests;
```

Alembic variant:

```python
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:

```sql
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:

```python
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`:

<!-- litestar-auth:change-password-request -->
```json
{
  "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:

```sql
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:

```python
from uuid import UUID

from litestar_auth import LitestarAuthConfig

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

After:

```python
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:

```python
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:

```python
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.

   ```python
   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.

```python
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,
)
```

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.
