Backend
Authentication
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.
ada@plate.dev or mfa@plate.dev (code 123456), password plate-demo-2026Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/signup | Creates 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/logout | Ends the current session. 204. |
| GET | /auth/me | The signed in user, with extra fields from other apps. |
| PATCH | /auth/me | name, locale, timezone, preferences (merged, a small object). Setting locale also sets the locale cookie. |
| DELETE | /auth/me | Deletes the account: {password}, or {confirm_email} for accounts without a password. 204. |
| GET | /auth/me/export | Every piece of the user's data as a JSON download (each app adds its section). |
| PUTDELETE | /auth/me/avatar | Uploads (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/resend | Sends 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/sessions | Signed 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-others | Signs out everywhere else → {count}. |
The user
{
"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():
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.
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.
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:
"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.
ada@plate.devSessions 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:
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:
| Key | Default | Meaning |
|---|---|---|
| SIGNUP_ENABLED | True | False closes signup (signup_disabled). |
| REQUIRE_EMAIL_VERIFICATION | False | Refuse logins until the email is confirmed. |
| LOGIN_MAX_ATTEMPTS | 5 | Failed attempts per account and IP in the window (a success clears it). |
| LOGIN_LOCKOUT_SECONDS | 900 | Length of the window. |
| LOGIN_ACCOUNT_MAX_ATTEMPTS | 50 | Failed attempts per account from any IP, against guesses spread over many addresses. |
| LOGIN_ACCOUNT_WINDOW_SECONDS | 3600 | Window of the per account cap. |
| LOGIN_IP_MAX_ATTEMPTS | 30 | Failed attempts per IP in the window; a successful login gives its attempt back. |
| REMEMBER_ME_AGE | 31536000 | Session age in seconds with remember (one year). |
| EMAIL_TOKEN_MAX_AGE | 259200 | Validity of verification and email change links (three days). |
| DELETE_ACCOUNT_REQUIRES_PASSWORD | True | Ask for the password before deleting. |
Links in emails are built from PLEATE["FRONTEND_URL"] and PLEATE["FRONTEND_PATHS"]:
| Path key | Default |
|---|---|
| VERIFY_EMAIL | /verify-email?token={token} |
| RESET_PASSWORD | /reset-password?token={token} |
| CONFIRM_EMAIL_CHANGE | /confirm-email?token={token} |
Error codes
| Code | Status | When |
|---|---|---|
| signup_disabled | 403 | Signup is closed. |
| email_taken | 400 / 409 | On the email field at signup or change; 409 when confirming a change to a taken email. |
| weak_password | 400 | On the password field: too short, too common, numeric or too close to the email. |
| invalid_credentials | 400 | Email or password is wrong. |
| too_many_attempts | 429 | Login locked for retry_after seconds. |
| email_not_verified | 403 | Verification is required and still pending. |
| wrong_password | 400 | Password check failed (change, email change, deletion). |
| confirmation_required | 400 | Deleting a passwordless account without typing its email. |
| invalid_token | 400 | Reset, verification or email change link expired or already used. |
| invalid_image | 400 | The 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_resetemail_changed(alsoold_email),sessions_revoked(alsocount)account_deleted, sent before deletion (alsoemail)collect_user_data, for the export