Skip to content

User Management

User management covers the full lifecycle: registration, authentication, profile updates, and deletion. This page documents the endpoints and patterns the boilerplate ships with.

Endpoints at a Glance

All under /api/v1/users/ (defined in modules/user/routes.py):

Method Path Description Auth
POST /api/v1/users/ Create a new user Open
GET /api/v1/users/ Paginated list of users Superuser
GET /api/v1/users/me Current user's profile Session
GET /api/v1/users/{username} Public profile by username (display fields only) Session
GET /api/v1/users/active-and-inactive/{username} Same as above, includes soft-deleted Superuser
PATCH /api/v1/users/{username} Update profile (own or admin) Session
DELETE /api/v1/users/{username} Soft-delete a user (own or admin) Session
DELETE /api/v1/users/db/{username} GDPR anonymize (admin) Superuser
GET /api/v1/users/{username}/rate-limits User's rate limits via tier Session
GET /api/v1/users/{username}/tier User's tier details Session
PATCH /api/v1/users/{username}/tier Change a user's tier Superuser

Plus the auth endpoints under /api/v1/auth/ documented in Sessions.

Registration

POST /api/v1/users/ is open — no auth required. Anyone can create an account.

curl -X POST http://localhost:8000/api/v1/users/ \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "username": "johndoe",
    "email": "john@example.com",
    "password": "Str1ngst!"
  }'

Every accepted signup answers the same: 201 with a fixed message and nothing of the account. An address that already has one is answered exactly like an address that was free, and the account holding it is sent a notice instead, so this route cannot be used to find out who is registered. A taken username still answers 409, because a username is public: a profile URL reveals it, and a signup form has to be able to say so. The created profile is not in the answer either: an answer carrying it would say whether the address was free. Read it from GET /api/v1/users/{username} once signed in.

The route delegates to UserService.create, which:

  1. Hashes the password with bcrypt via get_password_hash_async, before either credential is looked up. bcrypt is the slowest thing a signup does, so hashing only where the address was free would tell a caller by the clock what the answer deliberately does not
  2. Checks email is unique → raises EmailAlreadyRegisteredError if not, which the route answers with the fixed message, leaving the notice to the address owner to a background task so that path waits on no mail server either. Addresses are stored trimmed and lowercased, and ix_user_email_lower keeps the database from holding the same address twice in different case even when a write goes around the service
  3. Checks username is unique → raises UserExistsError if not (→ 409)
  4. Builds a UserCreateInternal (schema with hashed_password instead of password)
  5. Persists via crud_users.create. Where a unique constraint refuses the row rather than the checks above, two signups racing for one address or an address stored before it was canonicalised, the index that refused it decides the answer: ix_user_email or ix_user_email_lower gives the fixed message, ix_user_username gives 409. The index is read from the name asyncpg puts on the error, matched whole, so a later index whose name merely looks like one of those is not mistaken for it
# modules/user/service.py
async def create(self, user: UserCreate, db: AsyncSession) -> dict[str, Any]:
    if await crud_users.exists(db=db, email=user.email):
        raise EmailAlreadyRegisteredError("Email already registered")
    if await crud_users.exists(db=db, username=user.username):
        raise UserExistsError("Username already taken")

    payload = user.model_dump()
    payload["hashed_password"] = get_password_hash(payload.pop("password"))
    user_internal = UserCreateInternal(**payload)

    try:
        return await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead)
    except IntegrityError as collision:
        await db.rollback()
        raise UserExistsError("Email or username already taken") from collision

The UserCreate schema enforces input validation:

class UserCreate(UserBase):
    model_config = ConfigDict(extra="forbid")

    password: Annotated[
        str,
        Field(
            min_length=8,
            examples=["Str1ngst!"],
        ),
    ]

    @field_validator("password")
    def validate_password_strength(cls, v: str) -> str:
        """Reject a password missing any of the four character classes."""
        ...
    # OAuth fields (filled when user signs up via Google)
    google_id: str | None = None
    github_id: str | None = None
    oauth_provider: str | None = None

extra="forbid" rejects any unknown fields the client tries to send — useful to keep clients honest.

Authentication

Authentication happens via POST /api/v1/auth/login. See Sessions for the full flow. The credential check itself now lives inside the crudauth library — the login route delegates to the auth singleton (infrastructure/auth/setup.py), which looks the user up, verifies the password, and creates the session. The boilerplate no longer ships an authenticate_user helper.

Two things to note about the login behavior:

  • Username or email — both forms work in the same field
  • Soft-deleted users can't log in — crudauth reads User.is_active (defined as not is_deleted), so deactivated accounts are rejected before the password is even checked

Password Hashing (bcrypt)

Password hashing helpers come from crudauth:

from crudauth import get_password_hash, verify_password

hashed = get_password_hash("plaintext")          # bcrypt, salt generated automatically
ok = await verify_password("plaintext", hashed)  # constant-time compare

bcrypt handles salt generation automatically and is computationally expensive enough to defeat brute force at scale. UserService.create uses get_password_hash when persisting a new account (see Registration).

Profile Operations

Get Current User

curl http://localhost:8000/api/v1/users/me -b cookies.txt

Trivial route — get_current_user already returns the user dict:

@router.get("/me", response_model=UserRead)
async def get_current_user_profile(
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
    return current_user

Get User by Username

Public endpoint — no auth required. Filters out soft-deleted users.

curl http://localhost:8000/api/v1/users/johndoe

Returns 404 if not found or soft-deleted. The admin-only /active-and-inactive/{username} variant returns soft-deleted users too.

Update Profile

Users can update their own profile. A credential that carries a scope, an API key among them, needs user.update for that too, so a key minted for reading cannot rename the account behind it. Updating someone else's needs superuser, or the user.update permission. Tier updates are gated on a separate endpoint (see Permissions).

curl -X PATCH http://localhost:8000/api/v1/users/johndoe \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token>" \
  -d '{"name": "John Updated"}'

The service enforces the rules:

# modules/user/service.py
async def verify_update_permission(
    self,
    requester_user: dict[str, Any],
    target_username: str,
    permissions: Collection[str],
    *,
    is_superuser: bool,
    scoped: bool,
) -> None:
    if not scoped and self.is_self_or_superuser(requester_user, target_username, is_superuser=is_superuser):
        return

    if UserPermission.UPDATE.value in permissions:
        return

    raise PermissionDeniedError("You don't have permission to update profile on this user")

A user.update holder who isn't a superuser is then held to verify_no_privilege_escalation: the target must not be a superuser and must hold no permission the requester lacks. See Permissions.

If the body changes username, the service re-checks uniqueness.

Changing your email address

Not here: the address is not a field of this route, and sending one answers 422. An account moves its address through the confirmed flow, which emails the new address a link and tells the old one once it is used:

curl -X POST http://localhost:8000/api/v1/auth/email/change-request \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token>" \
  -d '{"new_email": "new@example.com", "password": "<current password>"}'

Confirming ends every session the account had, the one that asked for the change included: the address is what recovers an account, and the confirmation is a link from the new mailbox, so there is no session to keep. The owner signs in again, as their new address.

See Recovery Flows. The admin panel is the other way in: its form is built from UserAdminUpdate, which does carry email (along with the OAuth identifiers and the verification flag), so an administrator can correct an address without the owner confirming it.

The UserUpdate schema makes every field optional so clients can send partial updates. The address, the OAuth identifiers and the verification flag are not part of it, so sending any of them returns 422:

from .constants import (
    NAME_MAX_LENGTH,
    PROFILE_IMAGE_URL_MAX_LENGTH,
    USERNAME_MAX_LENGTH,
    USERNAME_PATTERN,
)


class UserUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Annotated[str | None, Field(min_length=2, max_length=NAME_MAX_LENGTH, default=None)]
    username: Annotated[
        str | None,
        Field(min_length=2, max_length=USERNAME_MAX_LENGTH, pattern=USERNAME_PATTERN, default=None),
    ]
    profile_image_url: Annotated[
        str | None,
        Field(
            max_length=PROFILE_IMAGE_URL_MAX_LENGTH,
            pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$",
            default=None,
        ),
    ]

The avatar is served to every signed-in account that reads the profile, so it is held to the length of a URL (2048). A picture an OAuth provider hands over is held to the same length and left out where it is longer, rather than written and served.

Deletion

The boilerplate distinguishes three deletion modes — pick based on what the request actually wants.

Soft Delete

DELETE /api/v1/users/{username} — sets is_deleted=True and deleted_at=now(). The row stays in the database; the user can no longer log in but their data is preserved.

curl -X DELETE http://localhost:8000/api/v1/users/johndoe \
  -b cookies.txt \
  -H "X-CSRF-Token: <token>"

Permission rules:

  • A user can soft-delete their own account
  • A superuser can soft-delete anyone
  • The caller must be signed in to a session; an API key is refused
  • With FRESH_SIGN_IN_SECONDS set, the sign-in must be no older than that window; unset, nothing is asked (asking for a fresh sign-in)

Hard Delete (database)

There's no public hard-delete endpoint by design — deleting rows from user would orphan all related data (sessions, API keys, etc.). If you really need it, use FastCRUD's crud_users.db_delete(...) from a script or admin task with full understanding of the foreign-key impact.

GDPR Anonymization

DELETE /api/v1/users/db/{username} — superuser only. Replaces PII with neutral values while keeping the row (and therefore foreign-key relationships) intact.

curl -X DELETE http://localhost:8000/api/v1/users/db/johndoe \
  -b superuser_cookies.txt \
  -H "X-CSRF-Token: <token>"

Service implementation:

async def anonymize_user(self, user_id: int, db: AsyncSession) -> None:
    anonymize_data = UserAnonymize(
        name="[DELETED]",
        username=f"deleted-{user_id}-{uuid.uuid4().hex[:12]}",
        hashed_password="DELETED_INVALID_HASH",
        profile_image_url="https://deleted.com/deleted.jpg",
        tier_id=None,
        is_superuser=False,
        google_id=None,
        github_id=None,
        oauth_provider=None,
        email_verified=False,
        oauth_created_at=None,
        oauth_updated_at=None,
    )
    # anonymize the row, then soft-delete it
    await crud_users.update(db=db, object=anonymize_data, commit=False, id=user_id)
    await crud_users.delete(db=db, id=user_id)

The hyphens in that name are outside USERNAME_PATTERN, which every signup and rename is held to, so nobody can take the name a deletion is about to need. A name that is held anyway, which takes a superuser writing one through the admin panel, is tried again with fresh hex up to ANONYMIZATION_ATTEMPTS times, and the deletion answers 500 rather than reporting a row it did not anonymize. Only a write the username index refused is named again; any other refusal is raised as it is, since no other name for it would help.

Email is intentionally retained for legal compliance purposes (audit trail, "right to be forgotten" doesn't always apply if the platform is required to keep records).

Keeping it also retires the address: the row holds it, soft-deleted, so

  • signing up with it again creates nothing and answers what every signup answers — the row still owns the address, and ix_user_email_lower owns it case-insensitively;
  • a verification or password-reset request for it sends nothing and answers exactly as it does for an address with no account, because the row is inactive (is_active is not is_deleted);
  • an OAuth sign-in with it is refused with account_inactive and links nothing, so a provider account can't claim the retired row.

Nobody inherits the address, and nothing can be delivered to it. If a person has to be able to sign up again with the same address, clear the row's email yourself as part of the request — at which point the anonymized row no longer records who it was.

Administrative Operations

List All Users

GET /api/v1/users/ — paginated, gated on the user.read permission (dependencies=[require_permissions("user.read")]). A role grants it; superusers always pass. See Permissions.

curl "http://localhost:8000/api/v1/users/?page=1&items_per_page=10" \
  -b cookies.txt

Response shape (via paginated_response):

{
  "data": [
    { "id": 1, "name": "Admin User", "username": "admin", "email": "admin@example.com", ... }
  ],
  "total_count": 42,
  "has_more": true,
  "page": 1,
  "items_per_page": 10
}

See Pagination for the full pattern.

View a User's Tier

curl http://localhost:8000/api/v1/users/johndoe/tier -b cookies.txt

Returns the user record joined with their tier. Permission: own profile or superuser.

Change a User's Tier

PATCH /api/v1/users/{username}/tier — superuser only.

curl -X PATCH http://localhost:8000/api/v1/users/johndoe/tier \
  -b superuser_cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token>" \
  -d '{"tier_id": 2}'

The service verifies the tier exists before assigning it. A tier_id outside 1..2147483647 answers 422, since the column cannot hold it.

View a User's Rate Limits

curl http://localhost:8000/api/v1/users/johndoe/rate-limits -b cookies.txt

Returns the rate limits configured for the user's tier. Permission: own profile or superuser.

User Model Reference

The actual model lives in modules/user/models.py. Trimmed:

class User(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "user"
    __table_args__ = (
        Index("ix_user_email_lower", text("lower(email)"), unique=True),
    )

    id: Mapped[int] = mapped_column(
        "id", autoincrement=True, nullable=False,
        primary_key=True, init=False,
    )
    name: Mapped[str] = mapped_column(String(30))
    username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(50), unique=True, index=True)
    hashed_password: Mapped[str] = mapped_column(String(100))
    profile_image_url: Mapped[str] = mapped_column(
        String(2048), default="https://profileimageurl.com",
    )

    tier_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("tiers.id"), index=True, default=None,
    )
    tier: Mapped["Tier | None"] = relationship(
        "Tier", back_populates="users", lazy="selectin", init=False,
    )

    is_superuser: Mapped[bool] = mapped_column(default=False)

    # OAuth (filled when user signs in via Google/GitHub)
    google_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None)
    github_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None)
    oauth_provider: Mapped[str | None] = mapped_column(String(20), default=None)
    email_verified: Mapped[bool] = mapped_column(default=False)

    @property
    def is_active(self) -> bool:
        # Derived, not stored. is_deleted stays the single source of truth.
        return not self.is_deleted

Existing databases

username is 32 characters wide so OAuth signups fit the usernames crudauth generates. Tables created while it was 20 characters keep the old width until you alter them:

ALTER TABLE "user" ALTER COLUMN username TYPE VARCHAR(32);

If you manage the schema with Alembic, alembic revision --autogenerate picks up the change instead.

Mixins from infrastructure/database/models:

  • TimestampMixin — created_at, updated_at
  • SoftDeleteMixin — is_deleted, deleted_at

The is_active property is derived from not is_deleted — there's no separate column. crudauth reads it during login so a soft-deleted user can't authenticate; is_deleted remains the single source of truth.

Table name is user (singular).

Common CRUD Tasks

The same FastCRUD operations described in CRUD Operations work on users:

from src.modules.user.crud import crud_users

# Existence checks
await crud_users.exists(db=db, email="user@example.com")
await crud_users.exists(db=db, username="johndoe")

# Counts
total_active = await crud_users.count(db=db, is_deleted=False)
admin_count = await crud_users.count(db=db, is_superuser=True)

# Filtered queries
result = await crud_users.get_multi(db=db, tier_id=1, is_deleted=False, limit=20)

# Search by username substring
result = await crud_users.get_multi(db=db, username__icontains="ad")

Frontend Integration

Use cookies, not bearer tokens. The browser will send the session cookie automatically as long as you set credentials: 'include':

class UserClient {
    constructor(baseUrl = '/api/v1') {
        this.baseUrl = baseUrl;
        this.csrfToken = null;
    }

    async register(userData) {
        const res = await fetch(`${this.baseUrl}/users/`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(userData),
        });
        if (!res.ok) throw new Error((await res.json()).detail);
        return await res.json();
    }

    async login(username, password) {
        const res = await fetch(`${this.baseUrl}/auth/login`, {
            method: 'POST',
            credentials: 'include',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({ username, password }),
        });
        if (!res.ok) throw new Error((await res.json()).detail);
        const { csrf_token } = await res.json();
        this.csrfToken = csrf_token;
        return csrf_token;
    }

    async getProfile() {
        const res = await fetch(`${this.baseUrl}/users/me`, {
            credentials: 'include',
        });
        if (!res.ok) throw new Error('Failed to get profile');
        return await res.json();
    }

    async updateProfile(username, updates) {
        const res = await fetch(`${this.baseUrl}/users/${username}`, {
            method: 'PATCH',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': this.csrfToken,
            },
            body: JSON.stringify(updates),
        });
        if (!res.ok) throw new Error((await res.json()).detail);
        return await res.json();
    }

    async deleteAccount(username) {
        const res = await fetch(`${this.baseUrl}/users/${username}`, {
            method: 'DELETE',
            credentials: 'include',
            headers: { 'X-CSRF-Token': this.csrfToken },
        });
        if (!res.ok) throw new Error((await res.json()).detail);
        this.csrfToken = null;
        return await res.json();
    }

    async logout() {
        await fetch(`${this.baseUrl}/auth/logout`, {
            method: 'POST',
            credentials: 'include',
            headers: { 'X-CSRF-Token': this.csrfToken },
        });
        this.csrfToken = null;
    }
}

credentials: 'include' makes the browser send/store cookies cross-origin — pair this with CORS_ALLOW_CREDENTIALS=true and an explicit CORS_ORIGINS list (no *) on the server.

Security Considerations

Server-side validation

All input validation runs server-side via Pydantic schemas. Client-side checks are nice for UX but don't replace server validation.

Login lockout

The login endpoint is automatically throttled by crudauth — an escalating per-IP / per-identifier lockout that returns 429 with a Retry-After header. There are no env vars to tune it. See Sessions.

Generic auth error messages

POST /api/v1/auth/login returns "Incorrect username or password" for both wrong username and wrong password — never reveal which one was wrong.

Soft delete for accounts

The default DELETE /api/v1/users/{username} is a soft delete. Hard deletion only for GDPR-style requests, with anonymization preserving FK integrity.

Next Steps

  1. Permissions — Role-based access control patterns
  2. Sessions — Cookie / CSRF / session lifecycle
  3. Production Guide — Hardening checklist