Changelog¶
Notable changes to CRUDAuth, newest first. CRUDAuth is pre-1.0, so minor versions may include breaking changes; those are called out explicitly.
0.8.2 - 2026-10-07¶
A fix for the database store on SQLite under Python 3.11. No other changes.
Fixed¶
- A used email link no longer stalls a SQLite
DatabaseStoreon Python 3.11. Opening a verification, password reset or email change link a second time (a double click, or a mail scanner fetching it first) loses the store'sset_if_absentclaim, which went through a duplicate-key error. SQLAlchemy 2.0's aiosqlite adapter leaves a failed statement's cursor open, and on Python 3.11, whererollback()no longer resets pending statements and a failed one isn't reset either, that cursor kept its pooled connection holding SQLite's lock until it was garbage collected. Every other write to the database, logins and their lockout counters included, waited out SQLite's timeout and failed with "database is locked". On SQLite the claim is nowINSERT OR IGNORE, so losing it raises nothing. PostgreSQL, MySQL and MariaDB, and Python 3.10, 3.12 and 3.13, weren't affected.
Tests¶
- A lost claim on SQLite raises no error in the driver, on every Python version. The concurrent
set_if_absenttests that failed on 3.11 in CI pass there again; the full suite passes on 3.11 and 3.12.
0.8.1 - 2026-10-07¶
Your own database as the shared store, for several workers on PostgreSQL, MySQL or SQLite without
Redis, and the last pieces CRUDAdmin needs to run beside another CRUDAuth app: a logout it can call
from its own route, the ended session named in the logout hook, and lockout counters kept apart.
Also the provider profile on every OAuth sign-in, and a security fix: an account without a password
now needs a fresh sign-in before it adds a password or an authenticator. That fix is the one
breaking change: an older session gets a 403 from /set-password and passwordless MFA setup.
Added¶
DatabaseStoreandCRUDAuth(database_store=...). One switch moves every server-side store into two tables of your database: sessions and CSRF tokens, the one-time-token, OAuth-state and MFA stores, and the default rate limiter. The rate limiter matters as much as the sessions: with lockout counters in memory, each of N workers counts failures on its own and an attacker gets N times the attempts.DatabaseStoretakes anasync_sessionmaker(or anAsyncEngine) and opens a session per operation, so no connection is held between operations; it may point at a database of its own.SessionTransport(backend="database")selects it for sessions explicitly.DatabaseSessionStorageandDatabaseRateLimiterBackend, usable on their own throughstore.storage(prefix=...)andstore.rate_limiter(). The atomic operations stay atomic on every dialect: compare-and-setmodifyon a version column,set_if_absentby an insert that loses on the duplicate key,get_and_deletebyDELETE ... RETURNING(elsewhere a read, then a delete that hands the value out only if it removed the row), and each counter increment as one upsert. An expired counter restarts with a fresh expiry in that same statement.- Tables you control.
crudauth_storeandcrudauth_countersby default, renamable;store.create_tables()for development, or declared on your ownMetaDatafor Alembic. Keys compare exactly on every database, case and trailing spaces included (on MySQL and MariaDB the key columns take a binary collation without padding,utf8mb4_0900_binon MySQL 8.0.17+ andutf8mb4_nopad_binon MariaDB 10.2+). The columns are plain SQLAlchemy types, so an Alembic migration autogenerated from them runs as written, and a key longer than the 255-character column, such as a lockout key carrying a long email, is stored as its start plus a SHA-256 of the whole key. - Expiry and cleanup. Expiry is computed from the application's clock and stored as epoch
milliseconds, so every dialect agrees. Expired rows read as absent everywhere. Every
purge_everywrites (1000) across a store's stores and limiter, about as many expired rows are deleted from each table, in batches ofpurge_batch_size(500): enough to keep up with what those writes created, never a whole backlog at once. A purge that fails is logged rather than failing the write it followed. Or callstore.purge_expired()on your schedule, which deletes them all. - Deadlocks retried. On MySQL and MariaDB, concurrent inserts into a populated table take gap locks and occasionally deadlock. A transaction rolled back over a deadlock, a lock wait timeout or a serialization failure is run again, up to five times, instead of surfacing as a 500.
- Older SQLite. On SQLite before 3.35, which has no
RETURNING(Debian bullseye ships 3.34, Ubuntu 20.04 3.31), single-use values are consumed by a read and a delete that hands the value out only if it removed the row, and counters by anUPDATEorINSERTand a read back in one transaction. NoON CONFLICTthere either, so it works back to SQLite 3.22. SessionTransport.complete_logout(request, response, db), whatPOST /logoutdoes, for a logout route of your own: it ends the session the cookie names (after the CSRF check, on a live session), clears every transport's cookies, runson_after_logout, and returns whether a session ended./logoutnow calls it.session_handlein the logout hook's context, besideip_addressanduser_agent, so an audit log can name the session that ended as well as the one that started.CRUDAuth(rate_limit_prefix=...), a key prefix for the default rate limiter built fromredis_url,redis_clientordatabase_store. Two apps sharing one Redis or counter table shared their lockout counters, so failures in one locked the same username or IP out of the other.redis_rate_limiter(prefix=...)andDatabaseStore.rate_limiter(prefix=...)take one too, for arate_limiter=you build yourself.AuthHooks(on_oauth_login=...), called as(user, info, *, db, created, context)on every OAuth sign-in that reaches an active account: one that created it, linked it by email, or found it by provider id.infois the provider's normalizedOAuthUserInfo, so an app can keep provider data that changes over time in step, such as a GitHub login, which changes on rename and which the username CRUDAuth derives once can't stand in for. It runs after the disabled-account check, before any MFA challenge (once per sign-in, not again when the challenge is answered) and before the session, in redirect and JSON mode. Best-effort like every hook: it can't refuse the sign-in. A hand-written callback callsauth.hooks.run_oauth_login(...)afterget_or_create_user.auth.signed_in_recently(principal), whether the session signed in withinCRUDAuth(fresh_sign_in_seconds=600)or holds sudo, for an app's own routes that add a lasting credential.auth.mfa_router, the/mfaroutes on their own, besideoauth_router,session_routerandbearer_router, for an app that doesn't mountauth.router. It raises without MFA configured.UserRepository.refresh_if_expired(db, user), which reloads a user a rollback expired.
Security¶
- An account without a password needs a fresh sign-in to add a password or an authenticator.
/mfa/totp/setupasked an account with a password for it, but an OAuth-only account for nothing, so any session cookie could enroll an authenticator. With sudo accepting a code, a stolen session became lasting sudo./set-passwordhad the same gap and a worse outcome: a stolen session could set a password, then change the email with it, and own the account. Both now require, of an account without a usable password, that the session making the request signed in withinfresh_sign_in_seconds(default 600) or holds an active sudo elevation; otherwise403("Sign in again to continue").0closes both routes to such accounts. A bearer token carries no sign-in time (access tokens are re-minted on refresh), so it's refused. Accounts with a password are unchanged at MFA setup;/totp/disableand/recovery-codes/regeneratealready require a current code, which a stolen session doesn't have.
Fixed¶
- A hook that rolls back no longer breaks the request it ran in. The hooks guide told a hook
that writes through
dbto roll back on its own failure. A rollback expires every row the session holds, and crudauth went on reading the user: the OAuth callback (afteron_after_register),/register, and an MFA login finished with a recovery code (afteron_after_recovery_code_used) raisedMissingGreenlet, a 500 for something that had succeeded. crudauth now reloads the user when a hook expired it, and rolls back what a hook that raised left uncommitted: a hook whose flush failed left the session raisingPendingRollbackError, which failed an OAuth sign-in into required MFA when the challenge then stored its pending secret. - A session store passed to
SessionTransport(storage=...)beside email or OAuth. 0.8.0 built the one-time-token and OAuth-state stores from the transport's backend, which for a supplied store is"custom", and failed at startup withUnknown session backend: 'custom'. Those stores now follow Redis or the database store when one is configured, and otherwise the transport's own backend, falling back to memory for a supplied store.
Breaking changes¶
/set-passwordand passwordless/mfa/totp/setupneed a fresh sign-in (see Security). A frontend that offers either long after sign-in should send the user through the provider again on the403, or raisefresh_sign_in_seconds. An app that called/set-passwordwith a bearer token for an OAuth-only account has to use a session, or its own route.
Documentation¶
- The storage guide's new "Database" section: setup, the tables and migrations, expiry and cleanup, and what's atomic on which database. The sessions guide and the agent skill point to it.
- The sessions guide covers a logout of your own and lockout counters for two apps side by side; the
hooks guide,
session_handleon logout. - The OAuth guide's "Provider data on every sign-in", with a GitHub-login example and the
hand-written callback;
on_oauth_loginand the rollback behavior in the hooks guide; the fresh sign-in in the MFA and passwords guides and the endpoint table; mountingauth.mfa_router.
Tests¶
- The storage conformance suite and a new contract suite run against memory, Redis (fakeredis) and
the database store on SQLite, and on PostgreSQL and MySQL in testcontainers (they skip without
Docker), including concurrent
modify,set_if_absent,get_and_deleteand increments. An end-to-end suite runs two workers on one database: a session made on one is good on the other, lockout counts failures from both, and signing out everywhere on one ends sessions on the other. - MariaDB 11.4 in a testcontainer, beside PostgreSQL 16 and MySQL 8.0.
- 300 concurrent creates, claims and increments, on distinct keys and on one key, into tables that already hold rows: no call fails, one claim wins. Without the retry it fails on MySQL every run.
- An Alembic autogenerate of the tables renders and runs with Alembic's own imports.
- Edge cases on every dialect: keys past the column width, keys differing only in case or a trailing space, non-ASCII prefixes and glob deletes, a failing purge, and a purge on a write deleting as many rows as writes were counted.
- A supplied session store with email and OAuth configured, the 0.8.0 regression.
- Every storage test also runs on SQLite forced onto the pre-3.35 paths, refusing any statement with
RETURNINGorON CONFLICT; the suite passed on real SQLite 3.22, 3.24, 3.31 and 3.34. A new CI job runs it on Debian bullseye's SQLite 3.34, on the container's own Python (uv would otherwise download the.python-versionone, with a current SQLite), and fails if SQLite isn't older than 3.35. complete_logoutfrom an app's route: the session ends, the cookies clear, and the hook names the session the login hook named; a live session without the CSRF header stays; no session, no hook.- Two apps on one Redis and on one database: with the default prefix a lockout in one locks the
other out, with
rate_limit_prefixit doesn't. - The route checks in two tests read the mounted paths from the OpenAPI schema. On FastAPI 0.142
and Starlette 1.7
router.routesholds nested routers, which failed one test and let the other pass without checking anything. on_oauth_loginthrough GitHub's own profile normalization: the login andcreatedon a first and a later sign-in, an email link, JSON mode, a write that persists and follows a rename, and no run for a disabled account (existing or newly created), a forged state or a failed token exchange; a hook that raises leaves the sign-in standing and its write undone; withMfaConfig(oauth=True)it runs once, before the challenge.- A passwordless account with a session older than the window gets
403at MFA setup and/set-password; a fresh sign-in, a wider window and an active sudo elevation are let through;0refuses; a bearer token is refused; an account with a password still proves it with the password. - A hook that rolls back on
/register, the OAuth callback and a recovery-code login: each request still succeeds (each raisedMissingGreenletbefore). A hook whose flush failed ahead of the MFA challenge's own write: the OAuth sign-in still reaches the challenge (it raisedPendingRollbackErrorbefore).
0.8.0 - 2026-10-06¶
What an app needs to move its users onto CRUDAuth from its own auth, and to run beside another CRUDAuth app, written for CRUDAdmin's move off its own session layer: old password hashes that still sign in, sessions namespaced per app, sessions kept in a store you supply, and hooks for the logins that fail. Sessions are also stored as HMACs of their ids now, can be capped from sign-in, and end when the same browser signs in again. Breaking: deploying it signs every user out once.
Added¶
CRUDAuth(legacy_verifiers=[...]), checks for password hashes another system wrote, tried at login when CRUDAuth's own check fails. A match signs the user in and replaces the stored hash with a CRUDAuth one, so the option is needed only until every user has signed in once.crudauth.utils.verify_plain_bcryptcovers hashes made by plain bcrypt, and refuses a password shaped like crudauth's own bcrypt input (the base64 SHA-256 digest), which plain bcrypt would otherwise accept against a crudauth hash. It compares the first 72 bytes of the password, all bcrypt ever hashed, so a long legacy password migrates the same on bcrypt 4 and 5. The verifiers run for an unknown user too, against a dummy hash, so a wrong password costs the same whether or not the account exists; one that raises is logged and counts as a non-match.SessionTransport(cookie_name=, csrf_cookie_name=, storage_prefix=, csrf_storage_prefix=), so two CRUDAuth apps can share a Redis and a browser. With the defaults, an admin panel and the app beside it read one key space, and a session from one resolved in the other.SessionTransport(storage=, csrf_storage=), session and CSRF stores you built (anyAbstractSessionStorage), in place of the memory or Redis ones.SessionTransport(absolute_timeout_hours=), the most a session may live from sign-in however active it stays. Unset by default, which keeps the idle timeout as the only one.AuthHooks(on_login_failed=, on_lockout=), run by every password login before it refuses: the identifier as typed, the account it named (orNone), and"invalid_credentials"or"inactive"; or the identifier and the lockout's remaining seconds.HookContext.session_handle, the session's public handle on a session or OAuth login, so an audit log can name the session without storing a credential.SessionManager.get_sessionandmodify_session, for code that reads or stamps a session by its cookie value.
Security¶
- Session ids and CSRF tokens are no longer stored. Each is kept under an HMAC of its value
keyed with
SECRET_KEY, and the stored session no longer carries its id, so read access to Redis no longer yields a working session. - Signing in again ends the session the browser presented. The browser drops the old cookie, so it stayed valid only for whoever had copied it.
Breaking changes¶
- Deploying 0.8.0 signs every user out once. Sessions written by 0.7 are stored under their
raw id and are no longer found. Changing
SECRET_KEYdoes the same from now on. SessionManager.session_handleis an instance method, and a handle is now the session's storage key rather than the SHA-256 of its id.auth.sessions.session_handle(session_id)keeps working; calling it on the class doesn't, and stored handles from 0.7 no longer match.- A second login from the same browser revokes the first session. An app that relied on the old session surviving a re-login in the same browser has to stop.
Documentation¶
- The sessions guide covers two apps side by side, your own session store, the session lifetime,
and what the store holds. The passwords guide covers moving users from another system, and the
hooks guide the two new hooks and
session_handle.
0.7.5 - 2026-10-06¶
The email flows now treat a disabled account the way 0.7.4 made OAuth treat it: as one that isn't there. No breaking changes.
Fixed¶
- An inactive account is sent nothing, and its links stop working.
/password/reset-requestand/email/verify-requestskip an account whoseis_activeis false, answering exactly as for an unknown address, so nothing about it is revealed. A reset, verification or email-change link sent before the account was disabled is refused at confirm with the usualInvalid or expired token. Before, a disabled account could be emailed a working reset link, and redeeming it set a new password, bumpedtoken_versionand ranafter_password_reset, though the account still couldn't sign in. A soft-deleting app that exposesis_activeas a property gets this for its deleted users too.
Documentation¶
- The email guide and the agent skill's
email.mdsay a disabled account is treated as absent.
0.7.4 - 2026-10-05¶
An OAuth sign-in on a disabled account was refused, but only after the account had been linked to the provider, and claimed if its email was unverified. It is now refused before anything is written. No breaking changes.
Fixed¶
- An inactive account is refused before linking.
OAuthAccountService.get_or_create_usernow raisesOAuthAccountExceptionwithaccount_inactivewhen the account it resolves, by provider id or by email, hasis_activefalse. Before, an email match ran the link first: the provider id was written onto the account and, when its email was unverified, the account was claimed (password made unusable, MFA removed,token_versionbumped, sessions ended, email marked verified). The callback then reportedaccount_inactive, so the user saw the refusal but the account had already changed. An app that disables an account to keep it as it was, or soft-deletes users behind anis_activeproperty, now gets that. An app callingget_or_create_userfrom its own callback gets the exception instead of the user.
Documentation¶
- The OAuth guide says nothing is written to a refused account, and shows how a soft-deleting app
maps its flag onto
is_activewith a property.
0.7.3 - 2026-10-05¶
An app that isn't served at the root of redirect_base_url can now say where a redirect-mode OAuth
callback lands when it has nowhere else to go. No breaking changes.
Added¶
CRUDAuth(oauth_default_redirect=...): where the callback sends the browser on failure (?error=<code>), on an MFA challenge (#mfa_challenge=...), and whenredirect_tois missing or unsafe. A same-origin path such as"/app/login"or an absolutehttp(s)URL; anything else is refused at startup. It defaults toredirect_base_url, as before.redirect_base_urlcan't carry the app's path, because it also builds the redirect URI registered with the provider, so a single-page app under/appnext to a landing page at/had its failed sign-ins land on the landing page.
0.7.2 - 2026-09-28¶
Guardrails for apps that register users through their own route instead of /register. The
allowlist has always protected crudauth's own endpoint; nothing helped an app audit the schema it
wrote itself, and accepting email_verified there defeats the OAuth account claim.
Added¶
REGISTRATION_ALLOWED_FIELDSandREGISTRATION_GATED_FIELDSare exported from the package root, next to the existingUserRepository.gated_register_fields(names), which answers with the privileged fields a set of names contains - by logical name and by mapped column, so acolumn_mapalias is caught. An app with its own signup route can assertnot auth.repo.gated_register_fields(SignUp.model_fields)in a test.
Documentation¶
- "Registering users from your own route" in the registration guide: what such a schema may
declare, why the row is built field by field rather than from
**payload.model_dump(), and whyemail_verifiedis the one that turns a careless signup into account takeover - the provider login finds an account that looks verified, skips the claim, and leaves the registrant's password working. - The same rule in the agent skill references, in
identity.md(the schema and the assertion) andoauth.md(that the claim only holds whileemail_verifiedis server-owned, and that an app doing its own linking has to claim as well).
0.7.1 - 2026-09-18¶
Two fixes that came out of moving the FastAPI boilerplate onto 0.7.0. A redirect-mode OAuth callback now sends a failed state check back to the app like every other failure, and rate-limit headers can now reach the responses a later dependency or the route refused. No breaking changes.
Added¶
RateLimitHeadersMiddleware(from crudauth.ratelimit import RateLimitHeadersMiddleware): copiesX-RateLimit-LimitandX-RateLimit-Remainingonto the responses that lost them because something after the limiter raised, such as a401fromcurrent_user(), a404from the route, or a422for a bad body. It only fills in missing headers, and a request no limiter counted gets none. It's opt-in.
Fixed¶
- A failed OAuth state check in redirect mode redirects: a callback with no state cookie, a
cookie from another sign-in, or a state that's used up or expired now goes to
redirect_base_url?error=invalid_state, and the state cookie is cleared. Before, it answered with a JSON400on the API's origin. JSON mode still answers400. - Several limiters on one route report the tightest: the headers describe the limiter with the least remaining, since that's the one the client will hit first. Before, the last limiter to run overwrote the others, so a route could report a loose budget while it was about to hit a tight one.
0.7.0 - 2026-09-17¶
Second factors, any identity provider, and a security pass over everything that was already here. TOTP two-factor authentication is opt-in and covers both login routes and OAuth; an OpenID Connect provider needs only its issuer; and five hardening branches went through the OAuth linking, session, recovery, password and rate-limit paths. The internals were split up along the way, which is the only place anything breaks.
Added¶
- TOTP two-factor authentication (
mfa=MfaConfig(...)): RFC 6238 codes with a ±1 step drift window, encrypted secrets (Fernet, with key rotation), hashed single-use recovery codes, and an atomic per-step claim so one code can't be spent twice. Seven routes under/mfa(verify,challenge, theGETstatus,totp/setup,totp/confirm,totp/disable,recovery-codes/regenerate), a login challenge that both/loginand/tokenhand off to, and anoauth=switch for whether a social login must also pass the second factor. Enrollment can be required for a group of accounts. - Any OpenID Connect provider from its issuer (
OAuthCredentials(issuer=...)): Keycloak, Zitadel, Authentik, Auth0, Okta or Entra ID need no provider class. Endpoints come from{issuer}/.well-known/openid-configurationduringawait auth.initialize(), the document must declare the issuer it was fetched from, the issuer must be https off localhost, and client authentication follows what the provider advertises (client_secret_post, or Basic when that's all it takes).GenericOIDCProvider.from_discovery(...)builds one outsideCRUDAuth. - Public OAuth clients:
client_secretis sent only when set, so a PKCE-only client isn't rejected for sending an empty one (thanks @carlosplanchon). - A return destination through the recovery links (
redirect_toon the three request endpoints): carried inside the signed token rather than the emailed URL, so it survives the link being opened on another device and there is nothing in the URL to edit. The confirm responses hand it back; same-origin relative paths only. - Configurable password policies (
password_policy=PasswordPolicy(...)): enforced on registration, set/change password, reset, and the direct service calls (thanks @emiliano-go). - Dynamic rate limits: per-request limit resolution,
KeyBy.USER_OR_IP, key callbacks that can read the principal, and public access to the configured backend (thanks @emiliano-go). auth.resolve_principal(request, update_activity=False)for middleware and other request-level code, sharing the per-request principal cache (thanks @emiliano-go).- Injected Redis clients on
CRUDAuthandSessionTransport: a client you pass stays yours and isn't closed on shutdown (thanks @emiliano-go). - A configurable OAuth router: custom paths and a JSON response mode alongside the redirect one (thanks @emiliano-go).
safe_redirect_pathexported for application routes - the same same-origin check the OAuth callback uses (thanks @emiliano-go).auth.oauth_providers, the configured providers by name, and an optionaltransport=on any provider for a proxy, a client certificate, or a test double.
Security¶
- OAuth account linking: an unverified local account is claimed rather than silently linked, a provider that doesn't report a verified email can't link to an existing account, and the callback no longer leaks which addresses exist.
- Sessions:
/sessionsand its delete route work on opaque handles instead of raw session ids, sessions are bound totoken_version, writes are compare-and-set, logout clears a bearer refresh cookie too, and a login the browser marksSec-Fetch-Site: cross-siteis refused. - Recovery: tokens are bound to the account state they were minted for (a password reset dies when the password or recovery value changes), the previous address is notified on an email change, and signup stays non-enumerable.
- Passwords: hashing and verification run off the event loop, the unusable-password path is timing-equalized, and passwords are NFKC-normalized before hashing.
- Rate limiting and lockout:
X-Forwarded-Foris read against a trusted-proxy boundary, TTLs are set atomically with the counter,clear_allcan't be used to wipe a spray, the lockout is configurable (LockoutConfig), and a correct password that still needs a second factor neither counts against the lockout nor clears it.
Changed¶
CRUDAuth.initialize()also initializes the configured OAuth providers, which is where an OIDC provider resolves its endpoints. An app that never called it now needs to.- The composition root was split up: the account and session-management routes, the rate-limit
dependencies, the OAuth path helpers and the transports' own routes moved into their own modules,
utils.pybecame a package, and the route builders take a typedAuthSurfaceinstead ofAny. Every documented import path is unchanged.
Breaking changes¶
EmailFlowService.confirm_recovery_verification/reset_password/confirm_email_changereturnEmailFlowResult(user, redirect_to)instead of the user row. Unpack it, or read.user; code that ignores the return value is unaffected.DELETE /sessions/{id}takes a session handle, and each entry'sidinGET /sessionsis that handle (the SHA-256 of the session id) rather than the session id itself. The response shape is unchanged; a client that stored the old values must re-read the list.- Internals that moved:
auth._apply_rate_limitis gone (useenforce_rate_limitorauth.rate_limit(...)), the per-feature store attributes are one registry,SessionInfois defined incrudauth.transports.session.management(importing it fromcrudauthis unchanged),_shared_routerand_add_session_management_routesare gone, and four transport helpers dropped their underscore:SessionTransport.enforce_csrf,BearerTransport.read_refresh,clamp_scopesandaccess_token. AbstractOAuthProvidergainedinitialize()and_ensure_ready()(both no-ops by default) and an optionaltransport=argument; a subclass that passes its arguments through is unaffected.
0.6.0 - 2026-06-21¶
CRUDAuth as a toolbox. The hardened auth flows that used to live only inside the route handlers
are now reusable primitives, the wired services are all reachable off auth, and the building
blocks are exported from the package root - so you can hand-roll a login, mint a token in a
webhook, or skip the routes entirely without losing the hardening. Everything is additive.
Added¶
auth.authenticate_password(db, identifier, password, *, request)- the credential check behind/loginand/token(shared escalating lockout, timing-equalized verification, disabled-account check), now a callable primitive onCRUDAuthandAuthRuntime./loginand/tokenwere de-duplicated onto it.auth.issue_tokens(user, *, scopes=None)(andBearerTransport.issue_tokens) - the bearer issuance behind/token, with scope clamping and thetoken_versionepoch. Reach for it instead of barecreate_access_token, which skips both./tokenissues through it;/refreshshares the underlying access-token minting.auth.emails(theEmailFlowService) andauth.oauth(theOAuthAccountService) accessors,Nonewhen unconfigured - matchingauth.repo/auth.sessions/auth.sudo.- Building blocks exported from
crudauth:UserRepository,SessionManager,SudoManager,EmailFlowService,OAuthAccountService, and the password helpersget_password_hash,verify_password,is_unusable_password,make_unusable_password. (Raw token-mint functions stay unexported on purpose - useissue_tokensso the clamp and epoch come along.) - A "Use the building blocks" cookbook recipe, and an end-to-end test suite against real Postgres (testcontainers) covering the login / token / refresh / lockout / revocation paths.
No breaking changes.
0.5.0 - 2026-06-21¶
Account & device management. The session/device endpoints apps kept hand-writing are now opt-in built-in routes, plus an in-session password change. Everything is additive.
Added¶
- Session & CSRF management routes (
SessionTransport(management_routes=True), off by default):GET /sessions(device list),DELETE /sessions/{id}(revoke one, ownership-checked,404if not found or not yours),POST /logout-all(with?keep_current=true), andPOST /csrf/refresh(re-mint a lost CSRF cookie; self-heals; the deliberate non-current_userrecovery path). Thin handlers over the existingSessionManager; the three mutating ones enforce CSRF via the session transport. POST /change-password(always mounted): change a known password while signed in. The current password is the re-authentication; a successful change bumpstoken_versionand revokes the user's other sessions (keeping the current one), and fireson_after_password_changed.401on a wrong current password,400on an OAuth-only account (use/set-password).on_after_password_changedhook, distinct fromon_after_password_reset(the token flow).SessionInfois now exported (theGET /sessionsresponse model), and a flat Endpoints API-reference page maps every mountable route in one place.SessionManager.set_csrf_cookie(...)(the CSRF half ofset_session_cookies, reusable on its own).
0.4.0 - 2026-06-21¶
Custom email bodies. EmailSender.send now receives an EmailContext, so you render your own
branded HTML for the verify / reset / change emails instead of delivering crudauth's plain text.
Added¶
EmailContextonEmailSender.send: the sender now gets the assembledlink(the token embedded in the URL),kind,recipient, andexpires_in, so it can build a real HTML template without parsing the link out ofbody. The context carries crudauth-owned render data only - never the bare token, never user-controlled fields - so a sender that drops it into HTML can't be an XSS or credential-leak vector.context.linkis the same assembled URL as inbody(one source). Per-user personalization (Hi Alice) stays aDeliveryChannelconcern (it has thedbhandle and owns escaping).- Bundled library skill (
crudauth/.agents/skills/crudauth/): crudauth now ships an embedded library skill, so AI coding agents follow crudauth's actual conventions and gotchas (account shapes, gates, recovery, custom email bodies, production wiring) in sync with the installed version. It travels in the wheel; install it into your project's agent withuvx library-skills(add--claudefor Claude Code).
Breaking changes¶
EmailSender.sendgains a requiredcontextparameter. Add it to yoursendsignature (async def send(self, *, to, subject, body, kind, context)); behavior is unchanged becausebodyis still the pre-rendered plain-text fallback, so a sender that ignorescontextproduces the same email as before.
0.3.0 - 2026-06-20¶
Account shapes. CRUDAuth's identity and recovery are now read from your model instead of assumed to be email, so an app can log in by username, recover by phone, or hold no email at all, with the same flows and the same security. Plus pluggable delivery channels, a server-side provisioning seam, and a ten-recipe cookbook.
Added¶
- Model-driven identity contract (
make_auth_identity()+IdentityConfig): the account shape is read from the model and the intent (login order, recovery factor) is declared inIdentityConfig, validated against the model at construction. Username-only accounts (no email) and non-email recovery become configuration, not forks. - Recovery-factor verification: "verified" now means the contract's recovery factor is proven
controlled, with email as the special case. A phone-recovery app verifies and resets over SMS,
and
current_user(verified=True)gates on the recovery factor. The verify and reset request endpoints are shaped to the factor, so a phone app drives them with{"phone": ...}. - Pluggable delivery channels (
DeliveryChannelport,channels=[...]): recovery tokens route over email, SMS, push, or any medium you implement; email is a built-in channel and every channel fires best-effort. - Provisioning seam (
new_user_fields/new_user_defaults): set app-owned columns on new users from a server-built context, on both/registerand OAuth signup, gated so a client can't reach a privileged column. - A Cookbook of ten from-scratch recipes (the three account shapes, OAuth, token APIs, existing-table onboarding, production), an Identity API reference page, and a refreshed architecture page.
Changed¶
current_user(verified=True)gates onrecovery_verified(which equalsemail_verifiedfor an email-recovery app) and raises at construction when the contract has no recovery factor.- The recovery
verify/resetrequest bodies are generated for the recovery factor; the change-email endpoints mount only when the model has anemailcolumn. - A non-email recovery factor emits a
{factor}_verifiedbookkeeping column (e.g.phone_verified) alongside the app-declared factor column.
Breaking changes¶
AuthHooks.on_after_email_verified→on_after_recovery_verified. The verification hook is factor-neutral now;on_after_email_changedkeeps its name (it proves a real email). Apps registering the old hook must rename it.EmailFlowService.request_email_verification/confirm_email_verification→request_recovery_verification/confirm_recovery_verification, and the verify / reset request methods take a factorvalueinstead ofemail. The service is constructed internally, so most apps are unaffected; direct callers must update.- Login resolves against the contract's
loginfields, replacing the@-in-identifier heuristic. The default (email + username) behaves the same.
0.2.1 - 2026-06-15¶
Changed¶
crudauth.__version__is now read from the installed package metadata rather than hardcoded, so it can't drift frompyproject.toml.
0.2.0 - 2026-06-15¶
A security and correctness pass over the extracted code, plus two capabilities the review surfaced as missing. Pre-beta, so fixes were made directly rather than behind shims.
Added¶
- Sudo mode (
sudo=SudoConfig()+auth.require_sudo()): short-lived re-authentication for sensitive actions, stamped on the session, with its own lockout and anon_after_sudohook. POST /set-passwordfor OAuth-only accounts to establish a first password while authenticated.- Token revocation for bearer tokens via a
token_versionepoch, bumped on password reset. - Atomic storage primitives (
set_if_absent/get_and_delete) and an atomicincrement_and_refresh_ttlon the rate-limiter backend. - Startup warning when an in-memory backend is active under what is likely a multi-worker deployment.
Changed / fixed¶
- Login hardening: trusted-proxy IP resolution (
trusted_proxy_hops), lockout-key canonicalization, timing-equalized verification (closes a user-enumeration oracle), and a SHA-256 pre-hash so bcrypt no longer truncates at 72 bytes. - Escalating lockout now re-arms its round TTL atomically, and a new
on_login_successknob controls what a good login clears. - OAuth:
stateis bound to the initiating browser (blocks login CSRF), the redirect target is hardened against open redirects, callback failures degrade gracefully, and a missing{provider}_idcolumn fails fast at startup. - Email: verify / reset / change consume tokens through the atomic one-time primitives; trigger emails are best-effort; the "existing account" notice is throttled.
- Repackaged into feature slices (
register/is now a package) with a documented import-direction architecture, and the test suite was reorganized into source-mirroring subpackages.
Breaking changes¶
/registeris a strict allowlist. Model columns are dropped unless named inregister_extra_fields.- Email endpoints renamed:
/email/verify-request,/email/verify-confirm,/password/reset-request,/password/reset-confirm,/email/change-request,/email/change-confirm. token_versioncolumn added toAuthUserMixin; a persisted schema needs the migration (or acolumn_mapentry) before bearer-token revocation works.check=now denies onFalseinstead of ignoring the return value.- Disabled accounts return the generic
"Incorrect username or password"(was a distinct error). - Bearer scopes are clamped to a grantable ceiling; tokens can't self-grant scopes.
- Storage and rate-limiter ports gained required methods; custom backends must implement
set_if_absent/get_and_deleteandincrement_and_refresh_ttl. - Removed:
OAuthToken,SessionData.is_active, and the single-argumentget_client_ip(request)signature (nowget_client_ip(request, trusted_hops=0)).
0.1.0 - 2026-06-13¶
Added¶
- Initial release, extracted from FastroAI into a standalone, transport-agnostic authentication library for FastAPI.
- One
CRUDAuthobject wiring session and bearer transports to a singlePrincipal, OAuth (Google / GitHub / custom), email flows, login lockout, rate limiting, pluggable memory/redis backends, lifecycle hooks, and acolumn_mapover your own SQLAlchemy model.