Backend

Authentication

Email and password accounts with sessions, password reset, email verification and change, data export and account deletion.

pleate_backend.accounts holds the user model (pleate_accounts.User, email as the username) and every account flow. It is mounted at /api/auth. Passwords are hashed with Argon2, must be at least 10 characters and pass Django's similarity, common and numeric validators.

Sign in, with the 2FA step

useLogin drives both forms: the password step, then the code step when the account has 2FA.

Mock backendada@plate.dev or mfa@plate.dev (code 123456), password plate-demo-2026

Endpoints

MethodPathPurpose
POST/auth/signupCreates an account: {email, password, name?, locale?} → 201 {user, verification_required}. Signs in unless verification is required.
POST/auth/login{email, password, remember?} → {user, mfa_required: false}, or {mfa_required: true, methods} when 2FA applies.
POST/auth/logoutEnds the current session. 204.
GET/auth/meThe signed in user, with extra fields from other apps.
PATCH/auth/mename, locale, timezone, preferences (merged, a small object). Setting locale also sets the locale cookie.
DELETE/auth/meDeletes the account: {password}, or {confirm_email} for accounts without a password. 204.
GET/auth/me/exportEvery piece of the user's data as a JSON download (each app adds its section).
PUTDELETE/auth/me/avatarUploads (multipart file, at most 2 MB, cropped to 512 px WebP) or removes the avatar.
GET/auth/avatars/<user_id>Serves an avatar to signed in users.
POST/auth/password/change{current_password, new_password}. current_password only when the account has one. 204.
POST/auth/password/reset{email}. Always 202, whether or not the email exists.
POST/auth/password/reset/validate{token} → {valid}, to show an expired link before the user types.
POST/auth/password/reset/confirm{token, new_password}. Verifies the email, signs out every session. 204.
POST/auth/email/verify{token} from the link → the user.
POST/auth/email/verify/resendSends the link again to the signed in user. 202.
POST/auth/email/change{new_email, password}. Emails a confirmation link to the new address. 202.
POST/auth/email/change/confirm{token} → the user with the new email. The old address gets a security notice.
GET/auth/sessionsSigned in devices: browser, OS, IP, last seen, which one is current.
DELETE/auth/sessions/<id>Signs one device out (yourself included). 204.
POST/auth/sessions/revoke-othersSigns out everywhere else → {count}.

The user

GET /api/auth/me
{
  "id": "6720c1f4a3b2c1d0e9f8a7b6",
  "email": "ada@plate.dev",
  "name": "Ada Lovelace",
  "display_name": "Ada Lovelace",
  "email_verified": true,
  "locale": "en",
  "timezone": "Europe/Rome",
  "preferences": { "theme": "dark" },
  "avatar_url": "/api/auth/avatars/6720c1f4a3b2c1d0e9f8a7b6?v=1a2b3c4d",
  "has_password": true,
  "is_staff": false,
  "date_joined": "2026-03-14T09:12:00Z",
  "extra": {
    "roles": ["editor"],
    "permissions": ["catalog.change_product"],
    "features": ["new-editor"],
    "mfa_enabled": true,
    "oauth_providers": ["google"],
    "subscription": { "plan_id": "pro", "status": "active", "current_period_end": "2026-11-14T09:12:00Z", "cancel_at_period_end": false }
  }
}

extra is filled by the installed apps. Add your own field from any app's ready():

python
from pleate_backend.accounts.me import register_me_field

register_me_field("household", lambda user: user.household_id and str(user.household_id))

On the frontend, useMe() returns the user or null (a 401 is not an error) plus hasRole, hasPermission and hasFeature helpers.

Signing up

Sign up

Server validation errors (taken email, weak password) land under the right field.

Mock backend

Signup sends the verify_email email with a link to FRONTEND_PATHS["VERIFY_EMAIL"]. With REQUIRE_EMAIL_VERIFICATION off (the default) the user is signed in at once and can verify later; with it on, login answers email_not_verified until the link is opened. The page behind the link calls useVerifyEmail(token).

Leaving verification optional has a cost: anyone can sign up with an address they do not own, then attach a social login, 2FA, API keys or webhooks before the real owner shows up. When the owner resets the password of an account that was never verified, all of that is removed (API keys are revoked, social links, 2FA and webhook endpoints deleted). Turn REQUIRE_EMAIL_VERIFICATION on if unverified accounts should not be usable at all.

Passwords

Forgot password

The answer is the same for every email, so the form cannot be used to find accounts.

Mock backend

The reset email links to FRONTEND_PATHS["RESET_PASSWORD"] with a token valid for PASSWORD_RESET_TIMEOUT (one hour). The token stops working once the password changes, and at most three emails per address are sent per hour. On that page:

app/reset-password/page.tsx
"use client";
import { ResetPasswordForm } from "pleate/auth/reset-password-form";
import { useResetPassword } from "pleate-client/react";
import { useSearchParams } from "next/navigation";

export default function ResetPassword() {
  const token = useSearchParams().get("token");
  const reset = useResetPassword(token, { onSuccess: () => location.assign("/login") });
  if (reset.checkingToken) return null;
  if (!reset.tokenValid) return <p>This link expired. Ask for a new one.</p>;
  return <ResetPasswordForm {...reset.resetPasswordFormProps} />;
}

A successful reset or password change signs out every other session, revokes every API key and emails a security notice. A reset also marks the email as verified, since the link proves the inbox.

Login protection

Failed logins are counted in sliding windows, before the password is checked, so parallel guesses cannot slip past the limit: per account and IP address (5 in 15 minutes), per account from any address (50 in an hour) and per IP address (30 in 15 minutes). Someone guessing from one address locks only that address out of the account, not the owner. Past a limit the endpoint answers 429 too_many_attempts with retry_after, even for the right password. A successful login clears its account and IP counter and gives back its other attempts; a password reset clears the account counters. The counters fail closed: if the cache is down, logins are refused rather than left unprotected.

Sessions

Signed in devices

Each session stores its browser, OS and IP. Revoke one, or every device but this one.

Mock backendSigned in as ada@plate.dev

Sessions use a MongoDB backed engine (pleate_backend.accounts.sessions) that keeps one row per session with the user, IP, user agent and last activity, refreshed by SessionActivityMiddleware. Ids in the API are derived from the session key, never the key itself.

Export and deletion

GET /auth/me/export answers a JSON file with the account and a section from each app (files, notifications, activity, API keys, conversations, billing...). Apps contribute through the collect_user_data signal:

python
from django.dispatch import receiver
from pleate_backend.accounts.signals import collect_user_data

@receiver(collect_user_data)
def export_orders(sender, user, request, **kwargs):
    return "orders", list(Order.objects.filter(user=user).values("id", "total", "created_at"))

Deleting the account requires the password (or typing the email when the account has none and DELETE_ACCOUNT_REQUIRES_PASSWORD is on), sends account_deleted, signs out every session and deletes the user. Foreign keys cascade; blobs that live outside MongoDB, such as files in S3, are removed by pre_delete handlers.

Settings

PLEATE["AUTH"], merged key by key with the defaults:

KeyDefaultMeaning
SIGNUP_ENABLEDTrueFalse closes signup (signup_disabled).
REQUIRE_EMAIL_VERIFICATIONFalseRefuse logins until the email is confirmed.
LOGIN_MAX_ATTEMPTS5Failed attempts per account and IP in the window (a success clears it).
LOGIN_LOCKOUT_SECONDS900Length of the window.
LOGIN_ACCOUNT_MAX_ATTEMPTS50Failed attempts per account from any IP, against guesses spread over many addresses.
LOGIN_ACCOUNT_WINDOW_SECONDS3600Window of the per account cap.
LOGIN_IP_MAX_ATTEMPTS30Failed attempts per IP in the window; a successful login gives its attempt back.
REMEMBER_ME_AGE31536000Session age in seconds with remember (one year).
EMAIL_TOKEN_MAX_AGE259200Validity of verification and email change links (three days).
DELETE_ACCOUNT_REQUIRES_PASSWORDTrueAsk for the password before deleting.

Links in emails are built from PLEATE["FRONTEND_URL"] and PLEATE["FRONTEND_PATHS"]:

Path keyDefault
VERIFY_EMAIL/verify-email?token={token}
RESET_PASSWORD/reset-password?token={token}
CONFIRM_EMAIL_CHANGE/confirm-email?token={token}

Error codes

CodeStatusWhen
signup_disabled403Signup is closed.
email_taken400 / 409On the email field at signup or change; 409 when confirming a change to a taken email.
weak_password400On the password field: too short, too common, numeric or too close to the email.
invalid_credentials400Email or password is wrong.
too_many_attempts429Login locked for retry_after seconds.
email_not_verified403Verification is required and still pending.
wrong_password400Password check failed (change, email change, deletion).
confirmation_required400Deleting a passwordless account without typing its email.
invalid_token400Reset, verification or email change link expired or already used.
invalid_image400The avatar is not a decodable image.

Signals

pleate_backend.accounts.signals. Audit, notifications and webhooks listen to them; so can you. All send user and request.

  • user_signed_up, email_verified, password_changed, password_reset
  • email_changed (also old_email), sessions_revoked (also count)
  • account_deleted, sent before deletion (also email)
  • collect_user_data, for the export