- Pluggable user id types. The user primary key no longer has to be a UUID:
UserSchemais now generic over its key type, soclass MyUser(UserSchema[int])gives you integer or sequence keys andUserSchema[str]gives you string keys. Everything else is unchanged -class MyUser(UserSchema)still means UUID, so existing applications need no edits at all. Adapters gainparse_user_id(), which converts a token subject back to the declared key type using the user schema you already pass in, so there is nothing extra to configure. Integer keys are enumerable in a way UUIDv7 keys are not, so choose deliberately if you expose ids. - Fail-fast guard for mismatched key types. Declaring
UserSchema[int]over a UUID table used to parse fine, miss every lookup, and return 401 on every request with nothing to point at the cause. Adapters now compare the schema'sidtype against the user model's primary key at construction and raise aValueErrornaming both sides. Storage whose key type cannot be read reliably skips the check rather than guessing.
UserIDwidened fromUUIDtoUUID | int | str. Runtime behaviour is unchanged, but this is a typing change for anyone who annotated withUserID- most notably custom adapter authors, since UUID-specific access such asuser_id.hexno longer type-checks. Either parameterise on your concrete key type or narrow at the point of use; adapters that only passuser_idthrough to a query need no change.
- Tortoise ORM adapter. A new
TortoiseAdapter(from fastapi_fullauth.adapters import TortoiseAdapter) plus a matching set of abstract Tortoise model mixins underfastapi_fullauth.models.tortoise(UserMixin,RefreshTokenMixin,RoleMixin,PermissionMixin,OAuthAccountMixin,PasskeyMixin), for apps built on Tortoise ORM. It implements the full feature set - roles, permissions, OAuth, passkeys, sessions, and atomic refresh-token rotation (via Tortoise'sin_transaction, with SAVEPOINT isolation for conflict-prone inserts). Roles and permissions use native Tortoise many-to-many relations you declare on yourUser/Rolemodels, so the adapter takes nouser_role_model/role_permission_model. Name your concrete modelsUser/Role/Permissionand register them under the Tortoise app labelmodels. Install withpip install fastapi-fullauth[tortoise](or[tortoise-standard]for the adapter plus every optional feature). See the Tortoise adapter guide. - Beanie (MongoDB) adapter. A new
BeanieAdapter(from fastapi_fullauth.adapters import BeanieAdapter) plus ready-to-use Beanie document classes underfastapi_fullauth.models.beanie(UserDocument,RefreshTokenDocument,RoleDocument,PermissionDocument,OAuthAccountDocument,PasskeyDocument), for apps on MongoDB via Beanie. It implements the full feature set - roles, permissions, OAuth, passkeys, and sessions. MongoDB has no foreign keys or join tables, so role membership is an embedded array on the user document and a role's permissions are an embedded array on the role document; the adapter takes nouser_role_model/role_permission_model. Refresh-token reuse detection is a single-document atomic compare-and-swap (find_one_and_update), so rotation stays correct without a replica set or multi-document transactions -transaction()is best-effort, and a crash mid-rotation orphans at most one refresh token without ever weakening reuse detection.delete_userremoves the user's refresh-token / OAuth / passkey documents explicitly, since MongoDB does not cascade. Install withpip install fastapi-fullauth[beanie](or[beanie-standard]for the adapter plus every optional feature). See the Beanie adapter guide. fullauth.hooks.on()works as a decorator. Call it with just the event name to register the function it decorates:@fullauth.hooks.on("after_register")overasync def on_register(user): .... The two-argument form (on(event, callback)) is unchanged.- Discord and GitLab OAuth providers.
DiscordOAuthProviderandGitLabOAuthProvider(from fastapi_fullauth.oauth import ...) join the existing Google and GitHub providers. Both support PKCE and require no app review to set up. Discord mapsglobal_name/usernameto the display name and builds the CDN avatar URL from the returned hash; GitLab uses standard OIDC userinfo (defaults targetgitlab.com- subclass and override the three endpoints for a self-hosted instance). See the OAuth guide. StandardOAuthProviderbase class for custom OAuth providers. The Google, GitHub, Discord, and GitLab providers now share one implementation of the standard authorization-code flow (authorize URL, code exchange with PKCE, userinfo fetch, uniform error handling). Custom providers whose identity provider follows the standard wire format can subclassStandardOAuthProvider(from fastapi_fullauth.oauth import ...), set the three endpoints plusname/display_name/default_scopes, and implement onlyparse_user_info(); providers with wire-format quirks override the small_authorize_params/_token_request_bodyhooks (see the GitHub provider). Subclassing the bareOAuthProviderABC keeps working unchanged.fastapi_fullauth.flows.refresh. The refresh-token rotation logic (compare-and-swap rotation, reuse detection with family revocation, and the non-rotating path) moved from the router into a reusable flow function, matching the other flows. The/auth/refreshendpoint behaves exactly as before; custom apps can now callrefresh(adapter, token_engine, token)directly. Reuse now raises the (previously unused)RefreshTokenReuseError.- Typed dependencies for custom user schemas. New
typed_current_user,typed_verified_user, andtyped_superuserfactories (from fastapi_fullauth.dependencies import ...) return the existing dependencies with their return type narrowed to yourUserSchemasubclass, so custom fields type-check without casts:CurrentUser = Annotated[MyUser, Depends(typed_current_user(MyUser))]. The ready-madeCurrentUser/VerifiedUser/SuperUserannotated types are now also importable fromfastapi_fullauth.dependencies, andAdapterFeature(theLiteralofsupports_feature()names) is exported fromfastapi_fullauth.adapters. fullauth checkCLI command. LoadsFULLAUTH_*config from the environment and.env, prints the resolved effective settings (including theBACKEND/PASSKEY_ENABLEDvalues that are inferred fromREDIS_URL/PASSKEY_RP_ID), and reports the warnings the app would emit at startup. Exits non-zero when the config fails to construct, so it doubles as a CI pre-flight check.
-
Password hashing no longer blocks the event loop. Argon2id and bcrypt are CPU-bound (tens to hundreds of milliseconds); running them inline serialized every concurrent request in a worker for the duration of each hash. All built-in flows (login, register, change-password, password-reset, the timing-defense dummy verify, and
create_superuser) now offload hashing and verification to a worker thread viaanyio.to_thread.run_sync, so concurrent logins run in parallel. New public helpersahash_password/averify_password(from fastapi_fullauth.core.crypto import ...) expose the offloaded variants for custom async flows; the synchronoushash_password/verify_passwordare unchanged and produce interoperable hashes. -
Event-hook registration now validates as you register. Registering a hook for an unknown event name (e.g. a typo like
after_registr) emits aUserWarningwith a "did you mean" suggestion instead of silently registering a hook that never fires, and a callback whose signature can't accept the event's arguments warns too. Custom events you emit yourself viafullauth.hooks.emit()still work; the unknown-event warning is informational. AddsEventHooks.has_listeners(event). -
Custom-claims hooks no longer run for failed logins. The login route computed
on_create_token_claimsbefore the password was verified, so the hook (and any services it calls) ran for every probing or failed attempt. Theloginflow now takes anextra_claims_providercallback and invokes it only after authentication succeeds; direct callers offastapi_fullauth.flows.logincan keep passing precomputedextra_claims, which the provider overrides when both are given. -
Redis-backed features share one client per URL. The token blacklist, lockout manager, per-route auth rate limiters,
RateLimitMiddleware, and the passkey challenge store each opened their own Redis client and connection pool fromREDIS_URL- eight or more pools to the same server in a default deployment. They now acquire one shared client per URL, released on shutdown when its last user closes; constructor signatures and behavior are unchanged. -
All middleware is now pure ASGI.
CSRFMiddleware,RateLimitMiddleware, andSecurityHeadersMiddlewareno longer subclass Starlette'sBaseHTTPMiddleware, removing its per-request task/stream overhead and its known interactions with background tasks and streaming responses. Registration (app.add_middleware(...)), constructor arguments, and observable behavior are unchanged. -
init_app()warns when the verify router is mounted without its email hooks. Email verification and password reset only deliver anything if you registersend_verification_email/send_password_reset_email; without them the endpoints return success while the token is silently dropped.init_app()now surfaces this at startup. Register the hook(s) beforeinit_app(), or exclude the router withinit_app(include_routers=...). -
Feature/adapter mismatch is now detected for the bundled SQL adapters. The startup warning that fires when a feature is configured against an adapter that can't serve it previously only checked whether the adapter implemented the matching mixin. The
SQLAlchemyAdapterandSQLModelAdapterstatically inherit every mixin, so the check never caught the real mistake: enabling passkeys or OAuth without passingpasskey_model/oauth_account_model. Capability is now reported from the model classes actually passed to the constructor (a newAbstractUserAdapter.supports_feature()), so the warning fires correctly and the affected routers are no longer mounted (they would have returned a 500 on first use). Custom adapters are unaffected: the default still reports capability from the implemented mixins. -
SQLModel mixin foreign keys now use
ondelete="CASCADE"to match the SQLAlchemy mixins, which already did. Refresh tokens, OAuth accounts, passkeys, and the role/permission association rows are now removed by the database when their parent user/role/permission is deleted, instead of being left orphaned. Migration: this changes the generated DDL for SQLModel users - regenerate or add a migration that recreates the affected foreign keys withON DELETE CASCADE.
- Refresh tokens are now stored as sha256 digests. The database previously held the raw refresh JWT, so a leaked database (or backup) handed out live sessions for up to
REFRESH_TOKEN_EXPIRE_DAYS. The flows now hash every token before storing or looking it up (new helperhash_refresh_tokeninfastapi_fullauth.core.crypto), so adapters - including custom ones - only ever see digests, with no schema change. Migration: existing stored raw tokens no longer match, so all active sessions are invalidated and users must sign in again. To preserve sessions instead, backfill before deploying: rewrite eachfullauth_refresh_tokens.tokenvalue to its sha256 hex digest. - Enumeration and timing defenses are now on by default.
PREVENT_REGISTRATION_ENUMERATIONandPREVENT_LOGIN_TIMING_ATTACKSboth flip fromFalsetoTrue, so a fresh install no longer reveals which emails are registered through registration responses or login timing. Breaking:/auth/registernow answers202+ a generic message instead of201+ the created user (and no longer 409s on duplicates); failed logins for unknown users cost one extra password hash. Set either flag toFalseto restore the previous behavior. - Client IP is now read from the right of the forwarded chain, defeating IP spoofing.
get_client_ipreturned the left-mostX-Forwarded-Forentry, which is client-supplied: any deployment that trusted the header (the required config behind a proxy) let an attacker rotate the header to dodge per-IP rate limiting and lockout. The resolver now takes the entryTRUSTED_PROXY_COUNT(new setting, default1, minimum1) positions from the right, since each trusted proxy appends the address it received from; left-most padding is ignored, and a chain shorter than the configured count falls back to the direct peer. Action: setTRUSTED_PROXY_COUNTto the number of proxies in front of the app when usingTRUSTED_PROXY_HEADERS.RateLimitMiddlewaregains a matchingtrusted_proxy_countargument. - Logout rejects purpose-scoped tokens.
/auth/logoutdecoded any access-typed token, so a password-reset or email-verify token (access-typed but carrying apurpose) could be presented as a session credential. It now rejects purpose-scoped tokens, matching the session dependencies. - Passkey authentication failures return a uniform 401.
/passkeys/authenticate/completepreviously answered 400 with a specific message for an unknown credential, clone detection, or an inactive user while signature/challenge failures returned a generic 401 - the difference let an attacker enumerate credentials and account state. All failures now return the same 401 and the reason is logged server-side. - Login timing defense matches the configured hash algorithm. With
PREVENT_LOGIN_TIMING_ATTACKS=Truethe unknown-user dummy verify always used argon2, so a bcrypt deployment still leaked user existence through the argon2-vs-bcrypt timing difference. The dummy hash is now built with the configured algorithm. - OAuth provider error responses are no longer logged with their body. The Google/GitHub token and userinfo error paths logged
response.text, which can contain the authorization code, client credentials, or user PII. Only the status code is logged now. - CSRF origin check no longer defers on an unparseable Referer. With
trusted_originsset, a request sending aRefererthat has no scheme/host (and no Origin) fell through to the token-only path. A present but unparseable Referer is now treated as a failed origin check. - Custom-claim hooks can no longer return a
purposekey.on_create_token_claimsreturning{"purpose": ...}landed in the token'sextra, which the session dependencies reject - silently breaking every login.purposeis now a reserved claim key and raises at claim-build time. - Startup warning when OAuth runs with the token blacklist disabled. OAuth state is single-use only via the blacklist; with
BLACKLIST_ENABLED=Falsea captured(code, state)pair is replayable within the state TTL. This is now surfaced as aUserWarning.
- Passkey
credential_idis nowVARCHAR(512)on the SQL adapters. It wasTEXT, which MySQL cannot build a unique index on (the same limitation already fixed for the refresh-token column), so the passkeys table failed DDL on MySQL. The Tortoise model already usedVARCHAR(512). Migration: SQLAlchemy/SQLModel users on MySQL should add a migration that altersfullauth_passkeys.credential_idtoVARCHAR(512); SQLite/PostgreSQL are unaffected. revoke_user_sessionis idempotent across databases. It returnedFalse(a 404 from the sessions route) when re-revoking an already-revoked family on MySQL, where an UPDATE that changes no rows reports rowcount 0. It now returnsTruewhenever the family exists for the user, on every backend.- In-memory rate limiter no longer leaks memory.
remaining()/reset_time()re-indexed adefaultdict, re-inserting an empty entry for every idle client IP the middleware touched on each response. They now read without re-inserting. - Rate-limit retry hints round up.
Retry-AfterandX-RateLimit-Resettruncated a sub-second window to0; they now round up, andRateLimitMiddleware's 429 also setsRetry-After. - SQL adapters no longer error on an empty update.
update_user/update_oauth_accountissuedupdate(...).values()with no columns when given an empty dict (a SQLAlchemy compile error); both now skip the no-op UPDATE, matching the Tortoise adapter. get_user_by_fieldrejects non-column fields. A field naming a relationship or method (e.g.roles) slipped past the check and produced an opaque SQL error; it now raises a clearValueError.- Redis lockout sets the lock and clears the counter atomically (one pipeline instead of two round-trips).
- Missing-model errors now name the exact constructor argument. When a role, permission, OAuth, or passkey method runs on an adapter that wasn't given the corresponding model, the raised
RuntimeErrornow lists the precise kwarg(s) to pass (e.g.Pass oauth_account_model.) instead of a generic "the corresponding model class" message. fullauth.aclose()no longer leaks resources when one fails to close. It closed each pooled resource sequentially with no error handling, so a failure on an early close (for example a Redis socket error during shutdown) skipped every remaining Redis pool and OAuth HTTP client, leaking them. Each resource is now closed independently; failures are logged and the rest still close.init_app()runsfullauth.aclose()on shutdown even under a customlifespan. Starlette ignores shutdown event handlers when an app is built with alifespan=, which previously leaked pooled Redis connections and OAuth HTTP clients.init_app()now wraps the app's existing lifespan so cleanup composes with it - the existing lifespan becomes the inner context andaclose()runs after its teardown, with no manual call needed. Callinit_app()afterFastAPI(lifespan=...)so it wraps your lifespan.- Clearer error when dependencies run before the app is wired.
FullAuth not initialized on app.statenow points toinit_app(app)(orbind(app)for manual router mounting).
- The homepage quick example now defines the
engine/session_makerit references, so it runs as written.
current_token_payloaddependency. A public FastAPI dependency that returns the decoded access-tokenTokenPayloadfor the request - reading the token from theAuthorizationheader or a cookie backend and validating it - without a database lookup. Use it to read custom claims frompayload.extrain your own dependencies instead of reimplementing token extraction.current_usernow builds on it, so behaviour is unchanged.
SQLModelAdapter(session_maker=...)no longer trips a type error. Itssession_makerparameter was typed as aSQLModel | SQLAlchemysession union, which - becauseasync_sessionmakeris invariant - rejected theasync_sessionmaker[AsyncSession]that the documented SQLModel setup (class_=AsyncSession) actually produces, forcing atype: ignoreat the call site. It now accepts the SQLModelAsyncSessiondirectly. Runtime behaviour is unchanged.
- New Custom Adapters guide with a complete, runnable in-memory adapter example, the key method contracts, and how to opt into roles, permissions, OAuth, passkeys, and sessions.
- New Customization hub linking every extension point, and a Recipes page with end-to-end examples (multi-tenant SaaS, username login).
- Fixed the custom-dependency examples in the claims and protected-routes docs, which referenced a private token-extraction helper.
- Session management. A new opt-in
sessionsrouter lets a user see and manage where they're signed in.GET /auth/sessionslists active sessions (one per refresh-token family) with device, IP, sign-in time, last-used time, and acurrentflag for the device making the request;DELETE /auth/sessions/{family_id}signs out one device;POST /auth/sessions/revoke-otherssigns out everywhere else. The bundled SQLAlchemy and SQLModel adapters support it automatically (the router mounts likeadmindoes for roles); custom adapters opt in via the newSessionAdapterMixin. Refresh tokens now record theuser_agentandip_addressthey were issued from, and the access token carries itsfamily_idclaim so the list can flag the current session. Migration: the refresh-token table gains two nullable columns,user_agentandip_address- add them with an additive migration (existing rows stayNULL). See Database Migrations. - Cookie transport now carries the refresh token. Previously
CookieBackendonly moved the access token, so a cookie-based app still had to hold the long-lived refresh token in JavaScript-reachable storage. The backend abstraction now transports the refresh token too:CookieBackendsets a separate HttpOnly refresh cookie (namefullauth_refresh, path configurable viaCookieBackend(config, refresh_path=...)), and/refreshand/logoutread it from the cookie, so cookie clients call them with no body. When a cookie backend is active the refresh token is kept out of the JSON response body entirely. This also wires the previously missing pieces of cookie support:/refreshnow re-sets the access cookie on rotation, and passkey authentication now sets the auth cookies like login and OAuth do. Bearer transport (the default) is unchanged.
TokenPair.refresh_tokenis nowstr | None. It stays populated under the default bearer transport; it isnullwhen a cookie backend carries the refresh token (the token lives only in the HttpOnly cookie). Clients that readrefresh_tokenfrom the body in bearer mode are unaffected.AbstractUserAdaptergained a defaulttransaction(). The base implementation yieldsselfwith no atomicity guarantee so existing custom adapters keep working unchanged; the bundled SQL adapters override it to run the block in one database transaction. Refresh-token rotation now uses it so revoking the old token and storing its replacement commit or roll back together.SecurityHeadersMiddlewareno longer emits HSTS on plaintext HTTP and defaultsX-XSS-Protectionto0.Strict-Transport-Securityis now sent only when the request is HTTPS (directly or via anX-Forwarded-Proto: httpsfrom a trusted proxy); a stray HTTP deploy can no longer pin sibling subdomains. The deprecatedX-XSS-Protection: 1; mode=block(which can introduce cross-site leak oracles) is replaced by0; rely on a Content-Security-Policy instead. HSTS is configurable viaSecurityHeadersMiddleware(app, hsts=..., hsts_value=...).CSRFMiddlewareacceptstrusted_origins. When set, state-changing requests carrying anOrigin/Referermust match one of the allowed origins - defence in depth that stops a cookie-injecting attacker even with a valid double-submit token. Requests with noOrigin/Referer(non-browser clients) still fall back to the token check.require_permissionfails with a clear error on an unsupported adapter. If the configured adapter does not implementPermissionAdapterMixin, the dependency previously raised a bareAttributeError(an opaque 500) on every protected request; it now raises a descriptiveRuntimeErrornaming the missing mixin.
- Logout now ends the session from the access token alone. Previously
/logoutonly blacklisted the short-lived access token and revoked the refresh-token family only if the client re-sent the refresh token, so a bearer client logging out with just itsAuthorizationheader left the refresh family alive and able to mint new access tokens until natural expiry. Logout now revokes the family using thefamily_idcarried on the access token, with the refresh-token path kept as a fallback for older tokens. - OAuth
stateis single-use. The state token is now burned on first use at the callback, so a captured(code, state)pair can't be replayed within the state's TTL (requires the token blacklist, on by default). - Token-role confinement is centralised in
decode_token.decode_tokengainedexpected_type/expected_purposechecks, now used by the session, refresh, email-verify, and password-reset paths, so a token minted for one role can't be accepted for another. - Explicit Redis failure policy. The token blacklist now fails closed (a Redis outage treats a token as revoked rather than letting a possibly-revoked token through), while the rate limiter fails open (a Redis outage allows the request rather than locking every client out of login). Both log the backend error.
- OAuth login now rejects deactivated accounts. The password (
login) and passkey flows already refused an inactive user; the OAuth callback did not, so a deactivated/banned user with a linked social account could still sign in. The callback now enforcesis_activebefore issuing tokens. - CSRF exempt paths are segment-anchored.
exempt_pathsmatched on a bare prefix, so exempting/api/fooalso exempted/api/foobar. Matching is now anchored on path-segment boundaries; only/api/fooand/api/foo/...match. - Password-reset and email-verification reject deactivated accounts.
loginand the OAuth/passkey flows already refused an inactive user, but a still-valid reset or verification token could be redeemed against an account an admin had deactivated. Both flows now enforceis_activeafter resolving the user, burning the token on rejection so it can't be retried.
- Revocations no longer over-retain in the blacklist. Logout, email-verification, and password-reset revocations passed no TTL, which made the in-memory blacklist grow without bound and made the Redis blacklist expire a long-lived verify/reset token after the short default TTL - letting it be replayed before its real expiry. Each revocation is now blacklisted for exactly the token's remaining lifetime.
- Redis rate limiter no longer undercounts bursts. The sliding-window sorted-set used the bare timestamp as the member, so requests within the same clock tick collided and were counted once, letting the limit be exceeded under concurrency. Each hit now uses a unique member.
- Redis rate limiter check-and-add is now atomic. Counting and adding ran as two separate round-trips, a check-then-act race that let a concurrent burst all read "under limit" before any of them incremented. The cleanup, add, and count now run in one
MULTI/EXEC, backing the hit out if it pushed the window over the limit. - Refresh-token rotation is atomic. Revoking the old token and persisting its replacement committed in separate transactions, so a crash between them could revoke a family's only live token with no successor - silently orphaning the session. Both now run in one transaction that rolls back together on failure.
- Email-verification tokens are single-use even when already verified. A verification token was burned only on the first successful verify; if the account was already verified by another path, the still-valid token was returned without being revoked. It is now blacklisted on any successful resolution.
decode_tokentolerates a non-dictextraclaim. A signed token whoseextraclaim was not an object raisedAttributeError(a 500) instead of decoding cleanly; it now coerces to an empty mapping.- Sessions router confines the token type.
GET /auth/sessionsandrevoke-othersresolve the current session withexpected_type="access", matching the centralised confinement used everywhere else. - OAuth provider clients are hardened. The shared
httpx.AsyncClientnow sets an explicit 10s timeout (instead of relying on the library default), and the Google/GitHub clients validate the token and userinfo responses, raisingOAuthProviderError(a clean 4xx) instead of aKeyError500 when a provider returns an unexpected body. family_idcolumn has an explicit length. The SQLAlchemy refresh-token model declaredfamily_idwithout a length, emitting an unbounded indexed column that fails DDL on MySQL; it is nowString(36), matching the SQLModel model.- SQLModel OAuth token columns no longer truncate. The SQLModel
OAuthAccountmappedaccess_token/refresh_tokento the defaultVARCHAR(255)while the SQLAlchemy model usedText; provider tokens longer than 255 characters were silently truncated on MySQL (or rejected in strict mode). Both SQLModel columns are nowText, matching the SQLAlchemy model. - Refresh-token
tokencolumn is a boundedVARCHAR(512)on both adapters. The SQLModel model used the AutoStringVARCHAR(255)default (truncating a refresh JWT on MySQL) while the SQLAlchemy model usedText(which MySQL cannot build the column's unique index on). Both are nowString(512)- wide enough for a refresh JWT and uniquely indexable on MySQL. - SQLAlchemy
Role.name/Permission.nameschema matches SQLModel. Both were declaredunique=Truewith no length and no index, so MySQL could not build the unique key (unboundedVARCHAR) and the two ORMs emitted divergent schemas. They are nowString(100)/String(200)with an index, matching the SQLModel mixins. - Token-blacklist TTL of
0no longer misbehaves. A0TTL was treated as "unset" viattl_seconds or default, making the in-memory entry live forever and the Redis entry silently take the 30-minute default (a literalsetex(0)would also raise). A non-NoneTTL is now floored to a finite 1s;Nonestill means no expiry. - Role and permission assignment is idempotent under concurrency. Two concurrent identical
assign_role/assign_permission_to_rolecalls could both pass the existence check and race to insert the same association row; the loser surfaced the composite-PKIntegrityErroras a 500. The conflict is now swallowed on the standalone path (the row the other caller created is the intended result), while inside atransaction()it still propagates. - Cookie misconfiguration is rejected early.
CookieBackendandCSRFMiddlewarenow raise ifsamesite="none"is set withoutsecure=True(browsers silently drop such a cookie, which would break auth). bcryptis now an installable extra.PASSWORD_HASH_ALGORITHM="bcrypt"was selectable but thebcryptpackage was never declared as a dependency, so choosing it raised a bareImportErrorat the first hash. Install it withpip install fastapi-fullauth[bcrypt](it's also bundled in thesqlmodel-standard/sqlalchemy-standardextras). Hashing or verifying a bcrypt hash without the package now raises an actionable install hint instead of a bare error, and verifying a stored bcrypt hash no longer fails the login silently when the package is missing.
fullauth secretCLI. A console command that prints a randomSECRET_KEYforFULLAUTH_SECRET_KEY, so first-run setup no longer needs thepython -c 'import secrets; ...'one-liner.- List settings accept a comma-separated string from the environment.
ORIGINS,TRUSTED_PROXY_HEADERS,PASSKEY_ORIGINS, andROUTER_TAGSnow takeFULLAUTH_ORIGINS=https://a.com,https://b.comin addition to the JSON-array form. JSON still works for values that need it. - Startup warnings for silent misconfiguration. Enabling passkeys (or passing OAuth providers) with an adapter that doesn't implement the matching mixin now warns instead of silently dropping those routes, and wiring a
CookieBackendwithoutCSRFMiddlewarewarns about the CSRF exposure. - Effective-config log line at init.
FullAuthlogs the resolved backend per subsystem plus passkey/OAuth status atINFO, so the inference fromREDIS_URLandPASSKEY_RP_IDis auditable at a glance.
- Cookie settings moved to the
CookieBackendconstructor (breaking).COOKIE_NAME,COOKIE_SECURE,COOKIE_HTTPONLY,COOKIE_SAMESITE, andCOOKIE_DOMAINare gone fromFullAuthConfig. Pass them when you build the (opt-in) cookie backend:CookieBackend(config, secure=..., samesite=..., domain=...). Bearer-token users, the default, no longer carry five cookie settings they never use. - Per-route rate limits collapsed into
AUTH_RATE_LIMITS(breaking). The fiveAUTH_RATE_LIMIT_LOGIN/_REGISTER/_PASSWORD_RESET/_PASSKEY_AUTH/_REFRESHfields are replaced by one typedAuthRateLimitsobject withlogin/register/password_reset/passkey_auth/refreshattributes (importable fromfastapi_fullauth). Set only the routes you want to change; the rest keep their defaults. Override in Python withAUTH_RATE_LIMITS=AuthRateLimits(login=10)or from the environment withFULLAUTH_AUTH_RATE_LIMITS='{"login": 10}'.AUTH_RATE_LIMIT_ENABLEDandAUTH_RATE_LIMIT_WINDOW_SECONDSare unchanged. PASSKEY_ENABLEDis inferred fromPASSKEY_RP_ID. SettingPASSKEY_RP_IDnow turns passkeys on without also settingPASSKEY_ENABLED=True. SetPASSKEY_ENABLED=Falseexplicitly to configure passkeys while keeping the routes off.- Removed
CSRF_SECRETfrom config (breaking). The library never read it; only the opt-inCSRFMiddlewareneeds a secret, and it already takes one. Wire it directly:app.add_middleware(CSRFMiddleware, secret=config.SECRET_KEY)(or pass your own key). REDIS_URLnow switches the backends on by itself. WhenBACKENDis left unset andREDIS_URLis configured, the effective backend becomesredisfor the blacklist, lockout, rate limiter, and passkey challenge store. Previously aREDIS_URLwithoutBACKEND=rediswas silently ignored and every subsystem stayed in-memory, which on a multi-worker deploy meant logout did not revoke across workers and lockout/rate-limit counters were per-process. An explicitBACKENDstill wins: setBACKEND=memoryto keep everything in-memory despite a configuredREDIS_URL, and individual*_BACKENDsettings continue to override per feature.
sqlmodel-standard/sqlalchemy-standardinstall extras. Each pulls one adapter plus every optional feature (redis,oauth,passkey), sopip install fastapi-fullauth[sqlmodel-standard]gets the full feature set without dragging in the other adapter.adapter.transaction()on the SQLAlchemy and SQLModel adapters. Runs several adapter calls in one transaction that commits together when the block exits or rolls back entirely on error. Conflict-prone inserts (create_user,create_oauth_account) use SAVEPOINTs so a unique-constraint hit rolls back only that statement and leaves the surrounding transaction usable. Works as-is on PostgreSQL and MySQL; on SQLite, configure the engine with SQLAlchemy's BEGIN-emulation recipe for correct SAVEPOINT/rollback behavior.- Injectable response schemas.
FullAuth(..., login_response_schema=..., message_response_schema=...)accept customLoginResponse/MessageResponsesubclasses (add optional fields to extend the token or message bodies).LoginResponse,MessageResponse, andTokenPairare now exported from the top-level package. FullAuth.enforce_rate_limit(request, route_name)resolves the client IP and applies the auth rate limit in one call.- PKCE for OAuth. The authorization-code flow sends an S256
code_challengeon authorize and the matchingcode_verifieron token exchange for providers that support it (Google and GitHub). The verifier is derived from the signed state token's nonce keyed bySECRET_KEY, so the flow stays stateless and the verifier never travels through the browser. This is defense-in-depth for a confidential client that already sends aclient_secret; it is not a substitute for binding the OAuthstateto the browser session. Enabled by default viaOAUTH_PKCE_ENABLED; custom providers opt in withsupports_pkce = True. - Resource cleanup via
FullAuth.aclose(). Closes pooled resources: Redis connections (blacklist, lockout, rate limiter, challenge store) and OAuth HTTP clients.init_app()registers it on app shutdown automatically; call it yourself if you pass a customlifespanto FastAPI. OAuth providers now reuse a single pooledhttpx.AsyncClientacross requests instead of opening one per call.
- Removed the
[all]install extra. It pulled in both database adapters, which no single application uses. Use[sqlmodel-standard]/[sqlalchemy-standard]for one adapter with every feature; contributors who need both adapters install them by name or runuv sync --all-extras. - Typed profile-update body.
PATCH /menow uses a model generated from the user schema's non-protected fields, so the updatable fields appear in the OpenAPI schema instead of a free-form object. Request handling is unchanged: protected fields are ignored and unknown fields still return 422. - Internal: the SQLAlchemy and SQLModel adapters now share a single implementation (
_BaseSQLAlchemyAdapter). Public adapter classes, signatures, and type hints are unchanged. - Internal: login, OAuth, passkey, and refresh-token rotation now share an
issue_token_pairhelper, and the per-route rate-limit plus client-IP boilerplate is centralized onFullAuth.enforce_rate_limit. - Internal: the permission mixin's cross-mixin dependency on
get_user_rolesis now expressed with aProtocolinstead of atype: ignore, and the shared adapter's session factory is precisely typed.
- bcrypt hashes with
$2a$and$2y$prefixes now verify. Password verification previously recognized only the$2b$prefix, so bcrypt hashes imported from other implementations or older versions were rejected and the user could not log in. All three prefixes are now accepted. decode_tokennow requires theexp,iat, andsubclaims. A validly signed token missing one of these raisesTokenErrorinstead of surfacing an unhandled error.
CurrentUser,VerifiedUser,SuperUserremoved from public API. Build your own typed dependencies withAnnotated[YourSchema, Depends(current_user)].- Factory functions removed.
get_current_user_dependency(),get_verified_user_dependency(),get_superuser_dependency()are gone. Usecurrent_user,current_active_verified_user,current_superuserdirectly withDepends().
get_fullauthexported fromfastapi_fullauth.dependencies. Gives custom dependencies access to the fullFullAuthinstance (adapter, token engine, config, hooks, etc.).- Architecture docs - explains how the library works internally (token lifecycle, adapters, protection subsystems).
- Passkeys docs - complete WebAuthn guide with setup, registration/authentication flows, frontend integration, clone detection.
- Frontend integration guide - framework-agnostic walkthrough of OAuth, passkey, email verification, and password reset flows.
- Testing guide - how to test apps built with fastapi-fullauth.
- Troubleshooting guide - common errors and solutions.
- All existing doc pages expanded with explanations, examples, and missing content.
-
hashed_passwordis nullable onUserMixin(both SQLAlchemy and SQLModel). OAuth-only users are inserted withhashed_password=NULLinstead of a fake random hash. The previoushas_usable_passwordboolean is gone;hashed_password IS NOT NULLis now the single signal. -
/auth/set-passwordroute removed. First-time password creation for OAuth-only users now goes through/auth/change-passwordwithcurrent_passwordomitted; the route accepts the missing field only when the stored hash isNULL. Users with an existing password must still supply it. The previousset_passwordflow checkedgetattr(user, "has_usable_password", True)against aUserSchemathat didn't include the field, so OAuth-only users on the default schema could never call it successfully; this is now closed. -
flows.set_passwordmodule removed. Folded intoflows.change_password, whosecurrent_passwordparameter is nowstr | None = None. -
AbstractUserAdapter.create_usersignature change.hashed_password: stris nowhashed_password: str | None. Custom adapters must acceptNoneand persist it. Built-in adapters already do. -
flows.oauth.link_or_create_userandflows.oauth.oauth_callbackno longer takehash_algorithm. OAuth users have no password to hash anymore. -
ChangePasswordRequest.current_passwordis nowstr | None. Clients that always sent it keep working; clients can omit it when the user has no stored password. -
ChallengeStoremoved fromcore.challengestoprotection.challenges. Import path change:from fastapi_fullauth.protection.challenges import ChallengeStore, InMemoryChallengeStore, RedisChallengeStore, create_challenge_store, register_challenge_store_backend. Also exported from thefastapi_fullauth.protectionpackage. The challenge store is a stateful anti-replay defence for WebAuthn; it belongs with the other defensive stores (lockout,ratelimit) rather than next toTokenEngineincore/. -
Built-in models are now mixins. The concrete
*Model/*Recordclasses and theFullAuthBasedeclarative base are gone. Bring your ownDeclarativeBase(SQLAlchemy) orSQLModeland combine each*Mixinto define the tables. The previous "must subclassFullAuthBase" rule forced every project to put its own tables on the library's metadata; mixins let you reuse oneBaseacrossfastapi-fullauthand the rest of the app.Before:
from fastapi_fullauth.adapters.sqlalchemy.models.base import FullAuthBase, UserBase from fastapi_fullauth.adapters.sqlalchemy.models.role import RoleModel class User(UserBase, FullAuthBase): __tablename__ = "fullauth_users" roles: Mapped[list[RoleModel]] = relationship(secondary="fullauth_user_roles")
After:
from sqlalchemy.orm import DeclarativeBase, Mapped, relationship from fastapi_fullauth.models.sqlalchemy import ( UserMixin, RefreshTokenMixin, RoleMixin, UserRoleMixin, ) class Base(DeclarativeBase): pass class RefreshToken(RefreshTokenMixin, Base): pass class Role(RoleMixin, Base): pass class UserRole(UserRoleMixin, Base): pass class User(UserMixin, Base): roles: Mapped[list[Role]] = relationship( secondary="fullauth_user_roles", lazy="selectin" ) refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="noload")
-
Model package moved to
fastapi_fullauth.models.{sqlalchemy,sqlmodel}. Old pathfastapi_fullauth.adapters.{sqlalchemy,sqlmodel}.modelsis gone. Class names also normalised to*Mixin:UserBase→UserMixinRefreshTokenModel/RefreshTokenRecord→RefreshTokenMixinRoleModel/Role→RoleMixinUserRoleModel/UserRoleLink→UserRoleMixinPermissionModel/Permission→PermissionMixinRolePermissionModel/RolePermissionLink→RolePermissionMixinOAuthAccountModel/OAuthAccountRecord→OAuthAccountMixinPasskeyModel/PasskeyRecord→PasskeyMixinFullAuthBase: removed
-
Adapter constructors take every concrete model as a keyword argument. Required:
user_model,refresh_token_model. Optional:role_model,user_role_model,permission_model,role_permission_model,oauth_account_model,passkey_model: pass only the ones for features you use. Calling a feature method without its model raisesRuntimeError.adapter = SQLAlchemyAdapter( session_maker=session_maker, user_model=User, refresh_token_model=RefreshToken, role_model=Role, user_role_model=UserRole, permission_model=Permission, role_permission_model=RolePermission, oauth_account_model=OAuthAccount, )
-
fastapi_fullauth.migrationsmodule removed.include_fullauth_models()andget_fullauth_metadata()are gone. The library no longer owns a metadata registry; your ownBase.metadatais the source of truth. Inalembic/env.py,import app.modelsto register the tables and settarget_metadata = Base.metadata. -
INCLUDE_USER_IN_LOGINconfig removed. Login, OAuth callback, and passkey-authenticate responses now always include theuserfield. The toggle existed only to preserve a pre-0.7 response shape; clients that key offuser is nullshould switch to reading the field unconditionally. -
ACCOUNT_LOCKED_EXCEPTIONremoved fromfastapi_fullauth.exceptions. Locked accounts have returned401(not423) since 0.9.0 to prevent enumeration via status code; the unused 423 helper is now gone too. -
ALGORITHMconstrained toLiteral["HS256", "HS384", "HS512"]. Free-form strings are rejected at config construction. Asymmetric algorithms (RS*/ES*) aren't supported yet; open an issue if you need them. -
SECRET_KEYmust be at least 32 characters when explicitly set. Short keys are rejected at config construction. Auto-generated dev keys already exceed this. -
Middleware is no longer auto-wired.
init_app()only mounts routers now;CSRFMiddleware,SecurityHeadersMiddleware, andRateLimitMiddlewareare imported fromfastapi_fullauth.middlewareand added withapp.add_middleware(...)like any other FastAPI middleware. Dropped: theauto_middlewarekwarg oninit_app(), the publicinit_middleware()method, and theCSRF_ENABLED/INJECT_SECURITY_HEADERS/RATE_LIMIT_ENABLEDconfig flags.create_rate_limiter()is now exported fromfastapi_fullauth.protectionfor users who want Redis-backed global limits. -
exclude_routersrenamed toinclude_routersoninit_app(). Allowlist instead of denylist.include_routers=None(default) registers every available router, the same behaviour as before with no kwarg. Pass an explicit list (e.g.["auth", "profile"]) to opt in selectively.
No data migration is required; table names and column shapes are unchanged.
- Replace
fastapi_fullauth.adapters.{sqlalchemy,sqlmodel}.models.*imports withfastapi_fullauth.models.{sqlalchemy,sqlmodel}.*and rename to the*Mixinclasses. - Declare your project's
Base(or use the existing one). - Define a concrete class per feature group you use (
RefreshToken,Role,UserRole, etc.). - Pass all of them to the adapter via keyword args.
- Drop
include_fullauth_models(...)andget_fullauth_metadata(...)fromalembic/env.py. Importapp.models, thentarget_metadata = Base.metadata.
/auth/refreshnow requires the refresh-token row to exist before issuing a new token pair. Previously, a JWT that decoded cleanly (valid signature, unexpired) was enough; even if the corresponding row had been pruned or never existed. This affected both rotation and non-rotation paths.- Login timing oracle hardening (opt-in). New
PREVENT_LOGIN_TIMING_ATTACKS: bool = Falseconfig. When True,/auth/loginruns a dummy argon2 verify on the unknown-user and missing-password paths, so response time no longer leaks whether the email exists. Off by default because it adds ~argon2 time to every failed lookup; flip it on when enumeration via timing is in your threat model. - CSRF middleware no longer pulls config from env at instantiation.
CSRFMiddleware(secret=...)is now required and validated (≥ 32 chars)._resolve_secret()(which built a freshFullAuthConfigto pullCSRF_SECRET/SECRET_KEYon demand and auto-generated a randomSECRET_KEYif neither was set) is gone.FullAuthConfigalso gains a validator that fails at construction ifCSRF_ENABLED=Trueand the effective secret is shorter than 32 chars. /auth/refreshis now rate-limited viaAUTH_RATE_LIMIT_REFRESH(default 30 req/min per IP). Without this, an attacker holding a stolen refresh token could hammer the endpoint for fresh access tokens, or use the response shape as a token-validation oracle. The default sits well above legitimate usage (a single user typically refreshes a handful of times per session) but caps abuse./verify-email/request,/verify-email/confirm,/password-reset/confirm,/oauth/{provider}/callback, and/passkeys/authenticate/completeare now rate-limited using the existingpassword-reset,login, andpasskey-authenticatebuckets respectively. The reset/verify confirm endpoints were previously unbounded once an attacker possessed (or forged) a token candidate; OAuth callback and passkey completion are login flows but weren't gated.- Passkey authenticate-begin no longer leaks email existence. When a client passes
email,allowCredentialsis always a list (possibly empty) instead of being omitted for unknown emails. Without an email, the route still allows discoverable credentials. Previously, an attacker could enumerate accounts by comparing the response shape. - Malformed JWT
subno longer 500s. Token decoding succeeded butUUID(payload.sub)raisedValueErroron non-UUID values. Now caught at every call site (current_user,/refresh,verify_email,reset_password,logouthook) and treated as an invalid token (401/TokenError). verify_passwordno longer crashes on a malformed stored hash. A garbage value inhashed_password(corrupted row, migration bug) causedInvalidHashError(argon2) orValueError(bcrypt). Both are now caught and treated as a credential mismatch.
- Hooks are now isolated. A raising hook is logged via
fastapi_fullauth.hooksand the next hook still runs. Previously a single failing hook (e.g. an email-send raising on a transient SMTP error) aborted every subsequent hook and surfaced as a 500 to the client, even though the user had already been created / password reset / etc. Hooks fire after the primary side effect commits, so a notification failure should never undo the operation the response reports. SQLAlchemyAdaptereager-loadsrolesregardless of relationship lazy setting. Added a_user_query()helper mirroring the SQLModel adapter that callsselectinload(user_model.roles)when the model has arolesattribute. Used byget_user_by_id,get_user_by_field(andget_user_by_email),update_user,create_user, andget_user_roles. Previously these methods built a bareselect(user_model)and relied on the app to declarelazy="selectin"on the relationship; if the app left it default (select),_to_schematriggered an async lazy-load outside the session and raisedMissingGreenlet. Behaviour now matches the SQLModel adapter.- Passkey router preserves tracebacks for unexpected failures. The broad
except Exception as e: logger.error("...: %s", e)in/passkeys/register/completeand/passkeys/authenticate/completedropped the stack trace, making webauthn library failures effectively undebuggable. Now useslogger.exception(...)so the traceback lands in thefastapi_fullauth.routers.passkeylogger alongside the request log line. - SQLAlchemy
UserMixin.emailis nowString(320)to match the explicit length onOAuthAccountMixin.provider_emailand the SQLModel mixin'smax_length=320. Previously the unsized column produced MySQL/MSSQL default-length VARCHARs (255 / 256) that silently truncated long addresses; Postgres/SQLite were unaffected. The local-part can legally be up to 64 chars and the domain up to 255 (RFC 5321), so 320 is the right ceiling. /auth/refreshwas passingstr(user.id)toRefreshToken(user_id=...)which expectsUUID. Pydantic v2 coerced silently so there was no runtime break, but the path now passesuser.iddirectly; consistent withflows/login.pyand clean under static type checking.LoginResponseis now a real subclass ofTokenPairwithuser: UserSchema | None = Noneinstead of a dynamically created model with no static type. The dynamic factory still narrows theuserfield to the configured user schema for OpenAPI, butLoginResponse(...)calls now type-check cleanly in mypy/pyright.DELETE /oauth/accounts/{provider}was callingdelete_oauth_account(provider, user.id); passing the local user UUID where the provider'sprovider_user_id(e.g. a Google subject ID) was expected. The query never matched, so unlinking silently no-op'd. Now resolves the OAuth account for the current user first and passes the rightprovider_user_id. Returns404if the user doesn't have an account on that provider.FullAuth.get_custom_claimsannotated as-> dict[str, Any]instead of baredict.- Passkey and OAuth flows now pass
user.id(UUID) toRefreshToken(user_id=...)instead ofstr(user.id), matching the password login path. - OAuth state without a stored
redirect_urinow falls back toprovider.redirect_uris[0]instead of passingNonetoprovider.exchange_code(...). Test mocks were unaffected; production callers always set it.
- Strict mypy is now clean across the entire codebase. The 184 strict-mode errors that had been parked for a future typed-hardening release are gone;
uv run mypy --strict fastapi_fullauthpasses 0/0. Mostly mechanical: missingdicttype args, missing parameter/return annotations,hash_password(algorithm=...)Literal at call sites, ASGI middleware app/call_next types. Mixin-method calls throughAbstractUserAdapterare now narrowed viacast()to the appropriateRoleAdapterMixin/PermissionAdapterMixin/OAuthAdapterMixinat each call site. SQLAlchemy 2.0 / SQLModel column-comparison stub limitations (where(self.user_model.id == user_id)types asboolinstead ofColumnElement[bool]) are scoped to the two adapter modules via a focused[[tool.mypy.overrides]]block; the rest of the codebase stays strict. - CI now runs
mypy --stricton every push and PR to keep the type surface clean. Development Statusclassifier bumped from3 - Alphato4 - Beta. Reflects 189 tests passing, multi-version CI on Python 3.10-3.14, OIDC-based PyPI publishing, the security hardening trail through 0.7.0-0.9.1, andpy.typedshipped. Reserved5 - Production/Stablefor v1.0.- Added
Operating System :: OS Independentclassifier (CI runs on Linux and Windows). - All dependency floors bumped to current stable versions:
fastapi>=0.136,pydantic[email]>=2.13,pydantic-settings>=2.14,pyjwt>=2.12,argon2-cffi>=25.1, plus extras (sqlalchemy>=2.0.49,alembic>=1.18,sqlmodel>=0.0.38,redis>=7.4,httpx>=0.28,webauthn>=2.7). - Version is now read dynamically from
fastapi_fullauth/__init__.pyvia[tool.hatch.version]so a bump only touches one file. [tool.pytest.ini_options]migrated to[tool.pytest](pytest 9 supports the flat key).pytest-covadded to the dev dependency group so contributors can runuv run pytest --cov=fastapi_fullauthlocally.AuthRateLimiter.check()now setsX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset, andRetry-Afterheaders on its429responses. The globalRateLimitMiddlewarealready sets theX-RateLimit-*triplet; the per-route auth limiter (login, register, password-reset, refresh, passkey-authenticate buckets) used to raise a bare429so clients couldn't tell when to retry. Headers come from the same limiter instance'sreset_time(client_ip)._b64_decodehelper inflows/passkey.pynow computes padding as(-len(data)) % 4instead of4 - len(data) % 4. Mathematically equivalent except when the input length is already a multiple of 4; the old form appended====(four bytes) instead of nothing.urlsafe_b64decodeis lenient enough to tolerate either, but the new form is the standard idiom.
py.typedmarker (PEP 561) ships with the package. Type annotations, including the genericFullAuth[UserSchema, CreateUserSchema]with PEP 696 defaults, the typedCurrentUser/VerifiedUser/SuperUserdependencies, and the adapter mixin surfaces, are now visible to mypy, pyright, and IDE language servers when the library is installed from PyPI. Previously the annotations existed internally but were treated asAnyby consumers.mypyadded to the dev dependency group for contributors who want to type-check locally. Not yet enforced in CI; strict mode has a backlog of ~190 existing errors (mostly missing return annotations, mixin-method lookups throughAbstractUserAdapter, and passkey-config narrowings) that will be cleaned up in a dedicated typed-hardening release.
- Lockout now returns
401instead of423 Locked. Clients that branched on423to render a "your account is locked" UI will silently fall into the generic credentials-error path. The change is deliberate; see Security below. - Email lookup is now case-insensitive. On case-sensitive database collations (MySQL default, SQL Server), rows registered with mixed-case emails (
Alice@X.com) will stop matching logins submitted in a different case. Run a one-offUPDATE fullauth_users SET email = LOWER(TRIM(email))before upgrading. PostgreSQL/SQLite with default collations are unaffected.
- Emails are now normalised (stripped + lowercased) on create, update, and lookup in both built-in adapters. Previously
Alice@X.comandalice@X.comcould register as separate accounts on case-sensitive collations (MySQL default, SQL Server). - Login now returns the same generic
401 Could not validate credentialsresponse for a locked account as for a wrong password. Previously a423 Lockedstatus let an attacker distinguish "email exists and is locked out" from "wrong password"; an enumeration signal once they'd exhausted the lockout counter on a target email. TheAccountLockedErrormessage no longer includes the identifier (cleaner logs too). - Opt-in
PREVENT_REGISTRATION_ENUMERATIONsetting (defaultFalse). WhenTrue,/registeralways responds202+{"detail": "If this email isn't already registered, a verification email has been sent."}whether the email was taken or not; attackers can't probe the user table through the registration endpoint. Off by default to keep the201+ user /409conflict shape that most client apps expect.
BearerBackendaccepts any case of theBearerauth scheme (Bearer,bearer,BEARER, mixed) per RFC 7235. Clients that sent a lowercase scheme were previously rejected with a 401.require_roletolerates aUserSchemasubclass with norolesfield; returns a clean403instead ofAttributeError/500. The default schema doesn't ship withroles; apps using RBAC still need to add it to their custom schema.hash_password(..., algorithm="bcrypt")rejects passwords over 72 UTF-8 bytes withInvalidPasswordErrorinstead of silently truncating. bcrypt's built-in truncation would otherwise cause subtle lockouts if an app later migrated to argon2id.- SQLModel
UserBase.hashed_passwordcolumn is nowText. Argon2id hashes are ~97 characters; MySQL / MSSQL defaultVARCHAR(255)was still fine but the column type is explicit now, matching the SQLAlchemy adapter. FullAuthConfigvalidates passkey settings at construction time whenPASSKEY_ENABLED=True: emptyPASSKEY_RP_ID/PASSKEY_ORIGINS, RP ID with scheme or path, origin without scheme, and Redis backend withoutREDIS_URLall raise at config creation instead of surfacing as 500s at first request.
- OAuth auto-link-by-email now requires
info.email_verified=Truefrom the provider when an account with that email already exists. Without this gate, any provider that returns an unverified email (e.g. GitHub secondary addresses) could be used to hijack an existing account by registering the provider with the victim's email. - Cookie backend's
delete_tokennow matches the samesecure/samesite/path/domainattributes used on set. Browsers ignore (or reject, forSameSite=None) a deletion that doesn't match; logout previously left the cookie in place on some setups. - Refresh-token revocation is now an atomic compare-and-swap (
UPDATE ... WHERE revoked=false). Two concurrent refresh calls with the same token can no longer both succeed by racing the old stored-state check.AbstractUserAdapter.revoke_refresh_tokennow returnsbool; custom adapters should honour the CAS semantics. create_usercatchesIntegrityErrorfrom duplicate-email races and raisesUserAlreadyExistsError. The register flow's pre-check only guards the common case; concurrent signups used to surface as 500s.- OAuth account table now has a composite unique constraint on
(provider, provider_user_id). Existing SQL users should autogenerate an Alembic migration to add it.create_oauth_accountnow returns the existing row on concurrent-insert collisions instead of erroring. - Password-reset and email-verification tokens now use their own TTLs (
PASSWORD_RESET_EXPIRE_MINUTES, default 15;EMAIL_VERIFY_EXPIRE_MINUTES, default 1440) instead of inheritingACCESS_TOKEN_EXPIRE_MINUTES. A production tweak to access-token lifetime for mobile clients no longer silently extends the window in which a stolen password-reset email grants an account takeover.
- Models split into packages:
adapters/sqlmodel/models.pyandadapters/sqlalchemy/models.pyare nowmodels/directories withbase.py,role.py,permission.py,oauth.py. Old import paths (from fastapi_fullauth.adapters.sqlmodel.models import ...) still work via__init__.pyre-exports. New selective imports:from fastapi_fullauth.adapters.sqlmodel.models.base import UserBase, RefreshTokenRecord. rolesremoved from defaultUserSchema: apps that use roles should extendUserSchemawithroles: list[str] = Field(default_factory=list). Apps without roles are unaffected.- Admin router auto-skipped when adapter doesn't implement
RoleAdapterMixin. OAuth/passkey routers auto-skipped similarly. AbstractUserAdapter.revoke_refresh_tokennow returnsbool: custom adapters need to returnTrueonly when the token actually transitioned from not-revoked to revoked (CAS semantics).
- Composable models: only imported model groups register tables. Apps that don't need roles/permissions/oauth skip those tables entirely.
- Selective migration helper:
include_fullauth_models("sqlmodel", include=["base", "role"])imports only specified model groups for Alembic. exclude_routersparam oninit_app():fullauth.init_app(app, exclude_routers=["admin"])to skip routers you don't need.bind(app)method: bind FullAuth to a FastAPI app for composable router usage. Called automatically byinit_app()andinit_middleware().init_middleware()method: wire up middleware independently when using composable routers.RouterNametype:Literal["auth", "profile", "verify", "admin", "oauth"]for type-safe router exclusion.AuthRateLimiterclass: per-route auth rate limiting extracted from FullAuth into its own class.exchange_oauth_code(),link_or_create_user(),issue_oauth_tokens(): OAuth callback split into composable flow functions.oauth_callback()still works as before (delegates to the three).register_lockout_backend(): register custom lockout backends forcreate_lockout()factory.register_rate_limiter_backend(): register custom rate limiter backends forcreate_rate_limiter()factory.- Passkey (WebAuthn) authentication: passwordless login with fingerprint, Face ID, security keys. Register, authenticate, list, and delete passkeys. Requires
pip install fastapi-fullauth[passkey]andPASSKEY_ENABLED=True. ChallengeStore: abstract challenge store with InMemory and Redis backends for WebAuthn flows.PasskeyAdapterMixin: adapter mixin for passkey credential persistence.- Adapter mixins:
AbstractUserAdaptersplit into composable interfaces:RoleAdapterMixin,PermissionAdapterMixin,OAuthAdapterMixin,PasskeyAdapterMixin. Custom adapters implement only what they need. Built-in adapters inherit all mixins (backward compatible).
- Adapter model imports are lazy; importing the adapter no longer registers role/permission/oauth tables
- Rate limiting extracted from FullAuth
__init__intoAuthRateLimiter - SQLModelAdapter
session_makertype hint accepts both session types cleanly TokenClaimsBuilderandRouterNamemoved totypes.pyinit_app()andinit_middleware()are now idempotent. Calling either twice on the same FastAPI app emits aUserWarningand is a no-op. Previously a second call (e.g.init_app(app)followed by a strayinit_middleware(app)) doubled the middleware stack; duplicate security headers, two rate-limiter instances halving the effective limit, and a CSRF layer validating another CSRF layer's cookies.- JWT decode now tolerates clock drift between services via
JWT_LEEWAY_SECONDS(default 30). Eliminates sporadic 401s caused by ±30 s skew between client and server clocks or across load-balanced instances. FullAuthConfigreads.envin the current working directory by default (env_file=".env"), and ignores unknownFULLAUTH_*vars instead of erroring (extra="ignore"). Local dev "just works" without passing_env_file=".env"explicitly. Cloud deployments are unaffected; pydantic-settings' precedence is init kwargs →os.environ→.env→ defaults, so platform-injected env vars always win, and a missing.envis a silent no-op. UseFullAuthConfig(_env_file="…")or aSettingsConfigDictsubclass to read a different file.
- InMemory adapter removed: use SQLModel + SQLite for prototyping instead.
UserIDis nowUUID(wasstr | int | UUID): all adapter methods,RefreshToken.user_id,OAuthAccount.user_id, andRoleAssignment.user_idare nowUUID.- OAuth providers passed as objects:
FullAuth(providers=[GoogleOAuthProvider(...)])replacesOAUTH_PROVIDERSdict in config.OAuthProviderConfigremoved. OAuthProvidersimplified: onlyredirect_uris: list[str](removed singularredirect_uri).get_redirect_uri()removed.redirect_urirequired in authorize URL: clients must pass?redirect_uri=in the OAuth authorize request.include_user_in_loginmoved to config: useFullAuthConfig(INCLUDE_USER_IN_LOGIN=True)orFULLAUTH_INCLUDE_USER_IN_LOGIN=trueenv var instead ofFullAuth(include_user_in_login=True).- Login response always includes
userfield: whenINCLUDE_USER_IN_LOGIN=False,userisnull(previously the key was absent). WhenTrue,usercontains the full user schema object.
- Redis lockout backend:
LOCKOUT_BACKEND="redis"for multi-worker deployments LOCKOUT_ENABLEDconfig: disable account lockout entirely (False)INCLUDE_USER_IN_LOGINconfig: include user object in login/OAuth callback responseLoginResponsedynamic model: login and OAuth callback routes now have properresponse_modelwith typeduserfield matching the configured user schemavalidate_profile_updatesflow: profile field filtering extracted from router toflows/update_profile.pyNoValidFieldsError,UnknownFieldsErrorexceptions for profile update validationchange_passwordflow: business logic extracted from profile routerPROTECTED_FIELDSClassVar onUserSchema: users can extend in subclasses- Password validation moved to flows (
register,reset_password,change_password) Makefilewithmake check,make test,make lint,make format,make docs, etc.
LockoutManageris now an abstract base class with async methodsInMemoryLockoutManagerreplaces the old syncLockoutManagermigrations/package flattened to singlemigrations.pymodule (import paths unchanged)- 4
type: ignorecomments fixed (replaced withgetattr, assertions,model_validate) - 204 routes (
delete_me,unlink_oauth_account) no longer return unnecessaryResponseobjects - Logout route return type corrected to
Response - All tests migrated from InMemory to SQLModel + SQLite
- Tests regrouped:
test_auth,test_profile,test_config,test_hooks,test_security,test_rbac UUID(payload.sub)conversion at token boundaries (dependencies, router, flows)- Removed
isinstancestr-to-UUID guards from adapters - Removed
str(user.id)/str(row.user_id)conversions; UUID used directly
InMemoryAdapterandexamples/memory_app/OAuthProviderConfigfrom configOAUTH_PROVIDERSfromFullAuthConfigFullAuth._build_oauth_providers()and_OAUTH_PROVIDER_REGISTRYOAuthProvider.get_redirect_uri()methodrbac/package (was empty, just re-exported fromdependencies)
- Config-only API:
FullAuthno longer acceptssecret_key=,**config_kwargs, or positionalconfig. Passconfig=FullAuthConfig(SECRET_KEY="...")or setFULLAUTH_SECRET_KEYenv var. All params are keyword-only. enabled_routesremoved: replaced by composable routers. Include only the routers you need instead of filtering route names.RouteNametype removed: no longer needed with composable routers.configure_hasher()removed: hash algorithm is now passed explicitly from config through flows. No more global mutable state.- Schema auto-derivation removed:
_derive_user_schema()and_resolve_create_schema()deleted from all adapters and FullAuth. Define your own schemas extendingUserSchema/CreateUserSchemaand pass them to the adapter. create_user_schemamoved to adapter: pass it to the adapter, not FullAuth:InMemoryAdapter(user_schema=MyUser, create_user_schema=MyCreate).
- Generic type parameters:
AbstractUserAdapter[UserSchemaType, CreateUserSchemaType],FullAuth[UserSchemaType, CreateUserSchemaType]with PEP 696 defaults for full type safety - Composable routers:
fullauth.auth_router,fullauth.profile_router,fullauth.verify_router,fullauth.admin_router,fullauth.oauth_router. Each lazily created, include only what you need - Typed dependency factories:
get_current_user_dependency(MyUser),get_verified_user_dependency(MyUser),get_superuser_dependency(MyUser)for custom schema type safety create_blacklist(config): extracted from FullAuth tocore/tokens.pycreate_rate_limiter(config, max, window): extracted from FullAuth toprotection/ratelimit.pyUserSchemaType,CreateUserSchemaTypeTypeVars exported from top-level packageUserSchema,CreateUserSchemabase classes exported from top-level package
- Router split: 613-line monolithic
create_auth_router()split intocreate_auth_router()(login/register/logout/refresh),create_profile_router()(me/update/delete/change-password),create_verify_router()(email verify/password reset),create_admin_router()(roles/permissions) - FullAuth slimmed: factory methods extracted, composable router properties added,
_OAUTH_PROVIDER_REGISTRYstays on class for now fullauth.routerstill works as before (composes all sub-routers),fullauth.init_app(app)unchangedhash_password()andpassword_needs_rehash()now accept explicitalgorithmparameter (defaultargon2id)- Shared request/response models extracted to
router/_models.py - RBAC permissions (
require_role,require_permission) available viafastapi_fullauth.dependencies
FullAuth._resolve_create_schema(): auto-derivation of create schema from ORM modelSQLModelAdapter._derive_user_schema(): auto-derivation of user schemaSQLAlchemyAdapter._derive_user_schema(): auto-derivation of user schema_SA_TYPE_MAPand_get_sa_type_map(): SQLAlchemy type mapping for auto-derivationFullAuth._create_blacklist(): moved tocore/tokens.create_blacklist()FullAuth._create_rate_limiter(): moved toprotection.ratelimit.create_rate_limiter()configure_hasher()and_algorithmglobal fromcore/crypto.py
- Structured logging across all auth flows, security middleware, and OAuth: failed logins, account lockouts, token reuse, CSRF violations, rate limit hits, role changes, and account deletions are all logged via
logging.getLogger("fastapi_fullauth.*") - Documentation site: MkDocs with Material theme, auto-deployed to GitHub Pages via CI
- Proxy-aware rate limiting: new
TRUSTED_PROXY_HEADERSconfig to read real client IPs fromX-Forwarded-Forand similar headers - SQLAlchemy example app (
examples/sqlalchemy_app/) update_userfield validation: rejects unknown fields with 422 instead of passing them to the DB- SQLModel adapter now accepts both SQLModel's and SQLAlchemy's
AsyncSession OAuthAccountRecordexported fromfastapi_fullauth.adapters.sqlmodel
- OAuth state token TTL was ignored:
OAUTH_STATE_EXPIRE_SECONDSconfig had no effect; state tokens usedACCESS_TOKEN_EXPIRE_MINUTES(30 min) instead of the configured 5 min - Refresh token reuse detection race condition: two concurrent
/refreshrequests could both succeed before either revoked the token; added explicit blacklist check before issuing new tokens - OAuth error messages leaked provider internals: raw API responses from Google/GitHub were exposed in HTTP error details; now logged internally and replaced with generic messages
- README rewritten with centered hero layout, badges, and documentation links
- Documentation URL updated in
pyproject.tomlto point to GitHub Pages
- OAuth2 social login: Google and GitHub out of the box, extensible for custom providers
GET /oauth/{provider}/authorize: get authorization URLPOST /oauth/{provider}/callback: exchange code for JWT tokensGET /oauth/providers: list configured providersGET /oauth/accounts: list linked OAuth accountsDELETE /oauth/accounts/{provider}: unlink a provider (with lockout prevention)
OAuthProviderabstract base class for implementing custom providersOAuthAccountandOAuthUserInfotypesOAuthAccountRecord/OAuthAccountModelfor SQLModel and SQLAlchemy adapters- OAuth adapter methods on all adapters (memory, SQLModel, SQLAlchemy)
OAUTH_PROVIDERS,OAUTH_STATE_EXPIRE_SECONDS,OAUTH_AUTO_LINK_BY_EMAILconfig fieldsafter_oauth_loginhook eventoauthoptional dependency group (pip install fastapi-fullauth[oauth])- Auto-link OAuth to existing user by email (configurable)
- Auto-verify email when provider confirms it
- Lockout prevention: can't unlink last login method
- Multiple
redirect_urisper OAuth provider: supports web, mobile, and production frontends from one config. Client passes?redirect_uri=on authorize, validated against allowed list.
create_refresh_tokenreturnsRefreshTokenMeta: previously returned a plainstr. Now returns aNamedTuplewith.token,.expires_at,.family_id. Callers that used the raw string must access.token.create_token_pairreturnstuple[str, RefreshTokenMeta]: second element is nowRefreshTokenMetainstead ofstr.revoke_all_user_refresh_tokensis now required on custom adapters: new abstract method onAbstractUserAdapter.
current_superuserdependency andSuperUserannotated typeCurrentUser,VerifiedUser,SuperUserannotated types independencies.current_userfor cleaner route signaturesRefreshTokenMetanamed tuple: avoids decoding freshly created tokens just to readexpires_atandfamily_idFullAuth.get_custom_claims(user): moved custom claims logic from router into the class, with validation against reserved JWT keys (sub,exp,type, etc.)revoke_all_user_refresh_tokens(user_id)on all adapters: bulk session revocation- Session revocation on password reset, password change, and account deletion
configure_hasher(): wiresPASSWORD_HASH_ALGORITHMconfig to the actual hasher; supportsargon2idandbcrypt- Automatic password rehash on login when hash algorithm or params have changed
- Register now checks uniqueness on
login_field(not just email) whenlogin_field != "email" InMemoryBlacklistnow respectsttl_seconds; expired entries are evicted on lookupRateLimiterevicts keys with empty timestamp lists to prevent unbounded dict growthdescriptionparameter on all route decorators for Swagger docs
current_active_verified_userwas missingpayload.type != "access"check; refresh tokens could pass through- Purpose tokens (password reset, email verify) could be used as regular access tokens;
current_usernow rejects tokens withextra.purpose - Duplicate token decode + user lookup across dependencies, router endpoints, and admin routes; consolidated into reusable
current_userdependency chain - Duplicate
roles+extra_claimsfetch in refresh route; pulled above the if/else branch - Login flow fetched the user from DB twice (once in router, once in
login()); now accepts pre-fetched user - Unused
request: Requestparameters in dependencies and routes - Removed duplicate docstrings on routes (kept
description=on decorators) require_permissionwas a full copy ofrequire_role; now delegates to it
- Route order follows auth lifecycle: register → login → refresh → logout → user → email/password → admin
require_role/require_permissionuseDepends(current_user)instead of duplicating token logic- Removed
_get_custom_claimsmodule-level function from router
- JSON login:
POST /loginnow accepts{"email": "...", "password": "..."}instead of form data. Swagger auth uses bearer token input instead of username/password form. - No default User model: SQLModel and SQLAlchemy adapters no longer ship a concrete
User/UserModeltable class. Users must define their own model fromUserBase. This eliminates relationship conflicts when subclassing. user_modelis required:SQLModelAdapter(session_maker, user_model=MyUser); no default.- Removed
min_length=8fromCreateUserSchema; password length is now fully controlled byPasswordValidatorandPASSWORD_MIN_LENGTHconfig. SQLAlchemyAdapterrenamedUserModeltoUserBase: importUserBaseinstead.
POST /auth/change-password: verifies current password, validates newPATCH /auth/me: update profile with protected field filteringDELETE /auth/me: self-deletionexpires_inin login/refresh responses- Per-IP auth rate limiting on login, register, password-reset (
AUTH_RATE_LIMIT_*config) LOGIN_FIELDconfig: login by email, username, phone, or any model fieldget_user_by_field()on all adapters for generic field lookups- Structured example apps (
examples/memory_app/,examples/sqlmodel_app/)
InMemoryAdapter.update_userreturning baseUserSchemainstead of custom schema- Stale
User.id/UserModelreferences in adapter queries after model removal - Parameter ordering in adapter constructors (required params before optional)
Initial release.
- JWT access/refresh tokens with rotation and blacklisting
- Argon2id password hashing
- Auth flows: register, login, logout, password reset, email verification
- Brute-force lockout, per-IP rate limiting, CSRF, security headers
- Bearer and cookie backends
- SQLAlchemy, SQLModel, and InMemory adapters
- Redis blacklist backend
- Refresh token persistence with family tracking and reuse detection
- Flat config (
secret_key=...) or fullFullAuthConfigobject - Auto-derive schemas from ORM model fields
- Auto-wire middleware from config flags
- Route enum, event hooks, email hooks
current_user,current_active_verified_user,require_roledependencies- 97 tests