Bundled auth flow (signup / activate / login / reset)¶
Since v0.31.0 the SDK ships the full local-account lifecycle — email + password signup, link-based activation, JWT-pair login, password reset — via UserAuthService + make_auth_router. Endpoints ready to mount (including POST /auth/refresh since v0.65.0), Jinja2 templates bundled, settings flags decide whether the link is emailed or returned in the response body, and five pre-thought modes: production, dev with local SMTP, dev without SMTP, CI and backend-only.
Jump straight to your case
The table below maps what the user wants to do → the section and endpoints. Start at your case; come back to setup only when you need to wire the pieces.
| I want to… | Logged in? | Section | Endpoints |
|---|---|---|---|
| Create account + activate by email | — | Setup | signup → activate/{token} |
| Test everything without a frontend (curl, Swagger, mobile app) | — | Backend only | signup → activate/{token} → login → protected route |
| The activation email never arrived | ❌ no | Re-send the activation | activation/request → activate/{token} |
| Log in | — | Endpoints | login |
| I forgot my password | ❌ no | Password recovery | password-reset/request → password-reset/confirm |
| Change my password | ✅ yes | Change password logged in | password-change |
| Change my email | ✅ yes | Change email | email-change/request → email-change/confirm |
| Re-verify my current email | ✅ yes | Re-verify email | email-verify/request → email-verify/confirm |
| I lost access to my email | ❌ no | Email recovery | email-recovery/request → email-change/confirm |
| My session expired | — | Refresh | refresh |
The two "I forgot": lost password → Password recovery; lost mailbox → Email recovery.
Recipe contents¶
- Minimum setup — extras install + wiring four objects (
AsyncDatabaseManager,EmailUtils,UserAuthService,make_auth_router). - Concrete UserTokenModel —
BaseUserTokenModelis abstract, your project owns the concrete table. - Endpoints — table of all endpoints + payload + behavior.
- Attempt limits — login and signup braked against brute force, without revealing which e-mails exist.
- Backend only — the signup → activation → login → protected route cycle without a frontend, measured with
curl, and the same cycle in a test.
- Re-send the activation — the activation email never arrived.
- Password recovery — the "forgot password" flow, step by step, plus changing the password while logged in.
- Email change and recovery — change email logged in, re-verify, and recover when the mailbox is lost.
- Settings — environment variables — env vars in groups (JWT, password policy, email flow, TTL, URLs/templates, backend pages) — each in a typed table, not one blob.
- Email anatomy: how link, template and URL fit together — disambiguates the three concepts that confuse readers the most.
- Email and page language (i18n) — default language, per-user preference and your own translation.
- Five operating modes — production, dev with local SMTP (Mailhog / smtp4dev), dev without SMTP, CI without activation, and backend-only (links and pages served directly by the backend).
- Token delivery — bearer, cookie or both.
- Mailhog vs smtp4dev — which to pick for local dev — comparison + copy-paste docker-compose snippets.
- Customizing email templates — override
activation.htmlandpassword_reset.html+ variables exposed to the Jinja2 context. - Security — token storage, TTL, anti-enumeration.
- Getting the
current_userfrom the request — the authenticated-user dependency in your routes. - Next steps.
Minimum setup¶
Requires:
[auth](bcrypt + PyJWT) — always required.[email](aiosmtplib + Jinja2 + email-validator) — optional; when missing, the link lands in the response body instead of an email.
Two files, and the direction between them matters. The shared objects —
connection, email, service — live under src/api/dependencies/; the
src/api/app.py imports from there and only assembles the application:
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import AsyncDatabaseManager, EmailUtils, UserAuthService
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
db = AsyncDatabaseManager(settings.DATABASE_URL)
# EmailUtils — only instantiate when [email] is installed AND you want real
# email (modes A and B below). In modes C and D, pass email=None to the service.
emails = EmailUtils(
host=settings.SMTP_HOST,
port=settings.SMTP_PORT,
username=settings.SMTP_USERNAME,
password=settings.SMTP_PASSWORD,
from_addr=settings.SMTP_FROM_ADDR,
template_dir="emails", # directory where your custom templates live
)
auth_service = UserAuthService(
db=db, # required for current_user_dependency (see final section)
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings, # mixes AuthSettings (see section 4)
jwt_settings=settings, # mixes JWTSettings
email=emails, # or None — controls real send vs link in body
)
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import make_auth_router
from src.api.dependencies.resources import auth_service, db
app = FastAPI()
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
),
)
The same get_session the rest of the app uses
session_factory takes the session dependency the app already has,
sub-dependencies included (get_session(database=Depends(get_database))):
a generator function (async or not) is registered as
Depends(session_factory), so app.dependency_overrides[get_session]
reaches /auth/login and the other auth routes too. Any other
zero-argument callable returning an async iterator keeps working as
before. The same rule holds for every SDK router that takes a
session_factory: push, Web Push, chat, reviews, wallet, sessions,
voice and app-errors.
The service cannot live in app.py
It is tempting to build the UserAuthService next to the app and
import it back from the dependencies — and that is an import cycle:
app.py imports the routers, the routers import dependencies/, and
that would import app.py again. The error lands far from its cause
(ImportError: cannot import name 'auth_service' from partially
initialized module), so the direction is fixed from the start:
dependencies never import app. It is also what keeps the service
testable without starting the application.
Four-object TL;DR
AsyncDatabaseManager → connection. EmailUtils → SMTP + Jinja2. UserAuthService → business rules. make_auth_router → glues it all into the HTTP endpoints: 13 with default settings, plus the MFA, cookie, social-login, WebAuthn and backend-page ones when enabled.
Concrete UserTokenModel¶
BaseUserTokenModel is abstract — your project owns the concrete table because the FK to users needs your table name. Example src/db/models/user_token.py:
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserTokenModel
class UserTokenModel(BaseUserTokenModel):
"""Concrete token table for activation / reset / email-verification."""
__tablename__ = "user_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
Re-export from src/db/models/__init__.py so Alembic picks it up:
from src.db.models.user import UserModel
from src.db.models.user_token import UserTokenModel
__all__: list[str] = ["UserModel", "UserTokenModel"]
Generate the migration (first time round, bootstrap Alembic with tempest db init):
# First time only — generates alembic/, alembic.ini and env.py:
uv run tempest db init
# Then the usual revision cycle:
uv run tempest db revision -m "users + user_tokens"
uv run tempest db upgrade
Endpoints¶
| Method | Path | Body / Output | Behavior |
|---|---|---|---|
| POST | /auth/signup |
SignupSchema → SignupResponseSchema |
Creates user. Not mounted when AUTH_SIGNUP_ENABLED=false (v0.272.0+). Emits email (modes A/B) or returns the link in the body (mode C). With AUTH_AUTO_ACTIVATE=True, the user is born active and the JWT pair returns immediately (mode D). |
| POST | /auth/activate/{token} |
— → ActivationResponseSchema |
Consumes token + sets is_active=True + issues JWT pair. A freshly signed-up account has no factor, so nothing changes; but if the token came from /auth/activation/request for a deactivated account that had MFA enrolled, it answers the challenge (mfa_required=true + mfa_token) instead of the pair. |
| POST | /auth/activation/request (v0.297.0+) |
ActivationRequestSchema → ActivationResendResponseSchema |
Unauthenticated. Re-issues the activation link for an account that never activated. Always 202 + generic body. |
| POST | /auth/login |
LoginSchema → LoginResponseSchema |
Email + password → JWT pair. Generic errors (no account enumeration). |
| GET | /auth/me (v0.198.0+) |
— → AuthUserSchema |
Authenticated. Returns the account owning the bearer token. Never serializes the password hash: the handler returns the whole model and the response_model filters. Swap in your own schema with me_response_model= to expose extra columns. |
| POST | /auth/password-reset/request |
PasswordResetRequestSchema → PasswordResetResponseSchema |
Always HTTP 202 + generic body. Link via email (A/B) or body (C). |
| POST | /auth/password-reset/confirm |
PasswordResetConfirmSchema → LoginResponseSchema |
Consumes token + writes new password + revokes sessions + issues JWT pair. With MFA enrolled on the account, it answers the challenge (mfa_required=true + mfa_token) instead of the pair — finish at /auth/mfa/verify. |
| POST | /auth/password-change |
PasswordChangeSchema → 204 |
Authenticated (bearer token). Change your own password: confirm the current password + write the new one. No email token. |
| POST | /auth/email-change/request (v0.92.0+) |
EmailChangeRequestSchema → EmailChangeResponseSchema |
Authenticated. Confirm the current password + email a confirmation link to the new address. Always 202. |
| POST | /auth/email-change/confirm (v0.92.0+) |
EmailChangeConfirmSchema → EmailChangeResponseSchema |
Consume the token + apply the new email + (optionally) notify the old address. |
| POST | /auth/email-verify/request (v0.92.0+) |
— → EmailChangeResponseSchema |
Authenticated. Re-send a verification link to the current email. |
| POST | /auth/email-verify/confirm (v0.92.0+) |
EmailChangeConfirmSchema → EmailChangeResponseSchema |
Consume the token + mark the account active. |
| POST | /auth/email-recovery/request (v0.92.0+, opt-in) |
EmailRecoveryRequestSchema → EmailChangeResponseSchema |
Unauthenticated. Recover an account whose mailbox is lost: password (+ MFA if enrolled). Only mounted with AUTH_EMAIL_RECOVERY_ENABLED=True. Always 202. |
| GET | /auth/oauth/{provider}/login (v0.273.0+) |
— → 302 |
Mints the state, writes it to an HttpOnly cookie and redirects to the provider. Mounted only with AUTH_OAUTH_ENABLED=true. See the social-login recipe. |
| GET | /auth/oauth/{provider}/callback (v0.273.0+) |
— → LoginResponseSchema |
Checks the state, trades the code, resolves (provider, subject) in the database and issues the same JWT pair as /auth/login — typ, opaque refresh, rotation and /auth/logout included. An account with TOTP gets the challenge (mfa_required=true + mfa_token), as at /auth/login (v0.314.0+). |
| GET | /auth/oauth/accounts (v0.273.0+) |
— → list[OAuthAccountSchema] |
Authenticated. Providers linked to the account. An empty list is a 200, not a 404. |
| POST | /auth/oauth/accounts/unlink (v0.273.0+) |
OAuthUnlinkSchema → 204 |
Authenticated. Detaches one provider. 404 when it is not linked to this account. |
| POST | /auth/refresh (v0.65.0+) |
RefreshSchema → LoginResponseSchema |
Exchange a valid refresh token for a fresh JWT pair. No email/password. Rejects a replayed access token (401) and inactive accounts (403). |
password-reset/confirm vs password-change — which is which?
These are different flows, don't mix them up:
/auth/password-reset/confirm— the user forgot their password. They're not logged in; they prove identity with the token emailed to them. (See/auth/password-reset/requestfirst.)/auth/password-change— the user remembers their password and is logged in. They send theaccess_tokenin theAuthorization: Bearer …header and re-confirm theircurrent_password. No email or reset token involved. Returns 204 and the current tokens stay valid.
Closed system — no registration door (v0.272.0+)¶
Not every service wants POST /auth/signup reachable. In a closed
system — accounts created by an administrator, never by whoever reaches the
door — the public registration route is exactly the hole you don't want open.
Turn it off with an environment variable:
Or per router, when one process mounts several and only one of them is closed:
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
UserAuthService,
make_auth_router,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
app = FastAPI()
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
allow_signup=False,
),
)
The argument wins over the setting in both directions;
allow_signup=None (the default) defers to AUTH_SIGNUP_ENABLED. It is the
same pair token_delivery already formed with AUTH_TOKEN_DELIVERY.
What the service answers now:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"whoever@example.com","password":"strong-pass-12"}'
404
Filtering router.routes after mounting is not equivalent
The workaround left before was dropping the route from the built list:
router.routes = [
route for route in router.routes
if getattr(route, "path", "") != "/auth/signup"
]
It matches a path string — going quiet the day the path changes —
and it says nothing to the schema: /openapi.json keeps advertising
a door that is no longer there. With AUTH_SIGNUP_ENABLED the route
is never registered, so it is gone from the application and from
the schema.
Activation stays mounted — deliberately
Turning signup off does not unmount /auth/activate/{token}. An
account created by an administrator still has to be activated, and
that endpoint is what consumes the emailed token. Nothing beyond
POST /auth/signup moves.
Recap. AUTH_SIGNUP_ENABLED=false closes the public door without
touching any other endpoint; allow_signup= overrides per router; and the
route leaves the OpenAPI schema along with it, which the manual filter never
delivered.
An account born with more than email and password (v0.278.0+)¶
Almost no product has an account that is only email, password and name.
There is the phone, the tax document, the flag that separates a customer from a
producer. Until now the way out was to unmount POST /auth/signup and write the
route by hand — and that hand-written route had to re-derive the password
policy, the 409 on a duplicate email, the activation branch and the JWT pair.
Four things the bundled route already gets right, and that start drifting one
release at a time.
One argument covers the common case: signup_schema replaces the request
body, and every new field on it that is also a column of your UserModel is
set on the row before the insert (v0.305.0+). For what is not a column —
a terms checkbox, a derived value — there is on_signup.
# src/api/app.py
from fastapi import FastAPI
from pydantic import Field
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
SignupSchema,
UserAuthService,
ValidationException,
make_auth_router,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
class ProducerSignupSchema(SignupSchema):
"""This product's signup: email, password, name — and the rest."""
phone: str = Field(max_length=20)
is_producer: bool = Field(default=False)
accept_terms: bool = Field(default=False)
async def require_terms(
session: AsyncSession,
user: UserModel,
payload: ProducerSignupSchema,
) -> None:
"""Refuse a signup that did not accept the terms.
Args:
session (AsyncSession): The transaction the row was inserted in.
user (UserModel): The freshly created instance.
payload (ProducerSignupSchema): The validated body.
Raises:
ValidationException: When ``accept_terms`` is false.
"""
if not payload.accept_terms:
raise ValidationException(message="accept the terms to sign up")
app = FastAPI()
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
signup_schema=ProducerSignupSchema,
on_signup=require_terms,
),
)
Here phone and is_producer are columns of UserModel (phone is
NOT NULL and UNIQUE), so they reach the row with no hook at all.
accept_terms is not a column: it stays only on the payload on_signup
receives.
With AUTH_AUTO_ACTIVATE=true in .env the account is born active and signup already returns the JWT pair. Under the default (false) the same call answers "activation_required":true with access_token and refresh_token set to null, and the pair only comes out of POST /auth/activate/{token}:
$ curl -s -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"strong-pass-12","phone":"5511999999999","is_producer":true,"accept_terms":true}'
{"user_id":"0e5cf2fc-…","activation_required":false,"activation_url":null,"access_token":"eyJ…","refresh_token":"eyJ…"}
A second signup with the same phone answers 409, with the column in
details and in field — not a 500 from an IntegrityError:
$ curl -s -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"bia@example.com","password":"strong-pass-12","phone":"5511999999999","accept_terms":true}'
{"detail":"phone already in use","code":"CONFLICT","details":{"columns":["phone"],"constraint":null},"field":"phone"}
constraint is null on SQLite, which does not name a unique constraint;
PostgreSQL reports the name (users_phone_key, for instance). The detail
above is from an app without a catalog; with register_exception_handlers(app,
catalog=default_message_catalog()) it comes out translated (Conflito de
recurso under pt-BR), and code, details and field do not change.
A protected column never comes from the body
id, email, hashed_password, is_active and is_admin
(SIGNUP_PROTECTED_FIELDS) belong to the SDK. A signup_schema with an
is_admin field promotes nobody: the route drops those names before
writing. Calling the service directly,
signup(fields={"is_admin": True}) raises ValueError.
Annotate the hook with your classes — the concrete model and the concrete
schema — as above: that is what makes payload.accept_terms type-check on
your side.
The schema must subclass SignupSchema — the route reads email,
password and name off it by name, so a body without them fails
make_auth_router at wiring time, not on the first request. The new fields
reach /openapi.json too: a generated client sees phone and is_producer
without anyone hand-writing a schema.
If your service overrides signup
Declare fields: Mapping[str, Any] | None = None on the override and pass
it on to super().signup(...): that is how the signup_schema columns
reach the row before the insert. An override without fields keeps
working — the route calls it without that argument, as before 0.305.0, and
logs, when the router is mounted, which columns are left for on_signup
to fill.
No router: signup(fields=...)
The same path lives on the service, for whoever builds their own route:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
async def create_driver(
service: UserAuthService,
session: AsyncSession,
email: str,
password: str,
phone: str,
) -> None:
"""Create the driver with the required phone already in the insert.
Args:
service (UserAuthService): The auth service.
session (AsyncSession): The request session.
email (str): Account email.
password (str): Plaintext password.
phone (str): Phone, a ``NOT NULL`` unique column.
"""
await service.signup(
session,
email=email,
password=password,
fields={"phone": phone},
)
await session.commit()
A key that is not a column of the model is a ValueError before anything
is written. A forgotten NOT NULL column is a ValueError too, naming the
column — a wiring defect, not a client error.
The hook runs before the commit, and that is the point
on_signup is awaited right after the insert and before
session.commit(), inside the same transaction. A hook that raises
takes the account down with it.
The alternative — writing the fields afterwards, in a second commit — is
the classic defect: a rejected document leaves behind an account with an
email, a password and none of the fields that make it usable. It shows up
nowhere in the product, yet it holds the email (which is UNIQUE), answers
POST /auth/password-reset/request, and blocks the correct signup with a
409 nobody can explain.
What does not change yet
The response is still SignupResponseSchema. When the client needs the
full profile right after signup, GET /auth/me already returns it — and
takes your own schema through me_response_model.
Recap. signup_schema is the input contract — a SignupSchema subclass,
visible in OpenAPI — and its fields that are columns land on the row before the
insert. on_signup is for the rest, inside the insert's transaction. Nothing
else has to leave the SDK for your service.
Login and signup attempt limits¶
Without a limit, POST /auth/login answers 401 to every wrong password
forever — brute force is only a matter of patience — and POST /auth/signup
creates accounts with no ceiling. The router ships both brakes, shaped like
mfa_throttle:
| Parameter | Key | Counts | Default |
|---|---|---|---|
login_throttle |
normalized e-mail (SHA-256) | every attempt, cleared by a right login | on: 5 per 900 s, in memory |
login_ip_throttle |
client IP | wrong credentials only; a right login does not clear it | off |
signup_throttle |
client IP | every attempt, right or not | off |
import asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AttemptThrottle,
AuthSettings,
BaseUserModel,
InMemoryThrottleBackend,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
register_exception_handlers,
)
class UserModel(BaseUserModel):
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
async def main() -> None:
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables()
service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=AuthSettings(AUTH_AUTO_ACTIVATE=True),
jwt_settings=JWTSettings(),
email=None,
)
app: FastAPI = FastAPI()
register_exception_handlers(app)
app.include_router(
make_auth_router(
service,
session_factory=db.session_dependency,
login_ip_throttle=AttemptThrottle(
InMemoryThrottleBackend(), max_attempts=20, window_seconds=900
),
signup_throttle=AttemptThrottle(
InMemoryThrottleBackend(), max_attempts=10, window_seconds=3600
),
trusted_ip_header="x-real-ip",
)
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
await client.post(
"/auth/signup",
json={"email": "ana@example.com", "password": "senha-forte-123"},
)
for password in ["senha-errada-123"] * 5 + ["senha-forte-123"]:
response: Response = await client.post(
"/auth/login",
json={"email": "ana@example.com", "password": password},
)
print(response.status_code, response.headers.get("retry-after"))
print(response.json())
await db.disconnect()
asyncio.run(main())
Output:
401 None
401 None
401 None
401 None
401 None
429 900
{'detail': 'too many login attempts, try again later', 'code': 'LOGIN_THROTTLED', 'details': {'retry_after_seconds': 900}}
The sixth attempt gets 429 with the right password: the budget is
reserved before the password is checked, so a burst of parallel guesses does
not all pass a "not blocked yet" read together. A right login before the limit
clears the e-mail's count.
The 429 does not reveal whether the e-mail exists
The e-mail budget is spent the same way for an address without an account,
and detail is the same text in both cases. Whoever probes the login
learns they guessed too often, not which e-mails are registered.
Why the per-IP limits ship off
Behind a proxy, the IP the app sees is the proxy's — without
trusted_ip_header, every client lands in the same bucket, and one per-IP
budget locks everyone out of login (or signup) at once. Turn
login_ip_throttle and signup_throttle on together with the
trusted_ip_header your edge overwrites ("x-real-ip"), never with
X-Forwarded-For.
Four more things worth knowing:
- With more than one worker, pass a shared backend. The default counts in
memory, per process;
AttemptThrottle(redis, max_attempts=5, window_seconds=900)shares the counter across replicas. login_throttle=Falseturns off the per-e-mail limit, for a service that already limits login in another layer.- Locking per e-mail has a cost: whoever knows someone's e-mail can spend its budget and keep the owner out for one window. That is the trade every per-account lock makes; the short window is what keeps it acceptable.
- The rest of the router is unchanged.
/auth/me,/auth/refreshand the other routes go through none of these limits, and the OpenAPI schema only declares the429on the login routes and/auth/signupwhile the matching limit is on.
Recap. Login comes protected per e-mail with no work from you; the per-IP limits you turn on once you know where the real IP comes from.
Backend only: from signup to a protected route¶
You don't have a frontend yet. Maybe you are testing the API with curl or
through Swagger, writing a mobile app, or you want the email link to
activate the account straight on the backend. The default
AUTH_ACTIVATION_URL_TEMPLATE points at
http://localhost:3000/activate?token={token} — a frontend page that, in your
case, does not exist.
This section runs the whole cycle with the API alone: sign up, get the
activation token, activate, log in and call a protected route. Every output
below came from a curl against the app on this page.
Step 1 — the whole app in one file¶
[auth] brings bcrypt and PyJWT, [sqlite] brings the aiosqlite driver,
and [email] brings the Jinja2 that the Mode E
HTML pages render with. uvicorn is the server.
# main.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import Depends, FastAPI
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AuthSettings,
AuthUserSchema,
BaseUserModel,
DatabaseSettings,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
)
class Settings(DatabaseSettings, JWTSettings, AuthSettings):
"""Settings read from the environment (and from `.env`)."""
class UserModel(BaseUserModel):
"""The account table."""
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
settings: Settings = Settings()
db: AsyncDatabaseManager = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
get_current_user = auth_service.current_user_dependency()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Open the database and create the tables on startup.
Args:
app (FastAPI): The application being started.
Yields:
None: Control back to FastAPI while the app serves requests.
"""
await db.connect()
await db.create_tables()
yield
await db.disconnect()
app: FastAPI = FastAPI(lifespan=lifespan)
app.include_router(
make_auth_router(auth_service, session_factory=db.session_dependency),
)
@app.get("/api/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> AuthUserSchema:
"""Return the account that owns the bearer token.
Args:
current (UserModel): The user resolved from the token.
Returns:
AuthUserSchema: The public view of the account.
"""
return AuthUserSchema.model_validate(current)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
Three lines do this section's work:
email=None— with noEmailUtils, the activation link comes back in the body ofsignup(theactivation_urlfield). You don't needAUTH_RETURN_TOKEN_IN_RESPONSE=true: withemail=Nonethe link shows up anyway, and it is the only place it shows up.db.create_tables()— createsusersanduser_tokensin SQLite (./app.db, theDATABASE_URLdefault). Fine for dev; in production the schema comes from Alembic, as in Concrete UserTokenModel.current_user_dependency()— the dependency guarding/api/profile. Details in Getting thecurrent_user.
Step 2 — the dev .env without a frontend¶
# .env
AUTH_BACKEND_LINKS=true
AUTH_ACTIVATION_URL_TEMPLATE=http://127.0.0.1:8000/auth/activate/{token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=http://127.0.0.1:8000/auth/password-reset/{token}
AUTH_BACKEND_LINKS=true turns on Mode E: the
backend mounts GET /auth/activate/{token}, an HTML page that activates the
account when someone opens the link. The template points at that route, so
the activation_url you get back is a link that works on its own.
127.0.0.1 only works because the link opens on your machine
In dev, whoever opens the link is you, on the same computer as the server. In production the link opens in the user's browser, and the host must be the backend's public domain — see the prefix warning in Mode E.
Start the server:
Step 3 — sign up¶
curl -X POST 127.0.0.1:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "senha-forte-123"}'
201 Created:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"activation_required": true,
"activation_url": "http://127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe",
"access_token": null,
"refresh_token": null
}
The token is the last segment of activation_url. The account exists but is
still inactive — logging in now answers 401 with
{"detail": "invalid email or password"}, the same sentence as a wrong
password, so nobody learns that the email has an account.
Step 4 — activate¶
Pick one of the two paths. The token is single-use, so the second use answers an error.
Single-use under concurrency too
Redemption is a single UPDATE ... WHERE used_at IS NULL RETURNING, and
the database picks the winner. Eight simultaneous password resets with
the same link, on separate sessions, give one success and seven
InvalidTokenException (tests/auth/test_consume_token_race.py, on
SQLite and PostgreSQL); up to 0.304.0 all eight passed on PostgreSQL.
It holds for activation, password reset and email change. RETURNING
needs PostgreSQL or SQLite 3.35+.
Open activation_url in the browser, or:
curl -i http://127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe
200 OK with content-type: text/html and the Account activated
page. It is what a Mode E user sees after clicking the email. Opening
the same link again answers 400 with the activation-failed page.
This page does not hand out a JWT (a token in a URL leaks into history and logs), so the next step is logging in.
curl -X POST 127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe
200 OK with the JWT pair — the account comes out activated and
logged in:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
Repeating it answers 401 with {"detail": "token already used"}. This
route exists in every mode; the GET one only with
AUTH_BACKEND_LINKS=true — without it, opening the link in the browser
answers 405 Method Not Allowed.
Step 5 — log in and call the protected route¶
curl -X POST 127.0.0.1:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "senha-forte-123"}'
200 OK:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"mfa_required": false,
"mfa_token": null
}
Send the access_token in the Authorization header:
200 OK:
{
"id": "69ce003d-1384-43a6-86df-01e547a03f13",
"is_active": true,
"created_at": "2026-09-27T22:16:42.190695Z",
"updated_at": "2026-09-27T22:16:48.642307Z",
"email": "ana@example.com",
"is_admin": false,
"last_login_at": "2026-09-27T22:16:48.641560Z"
}
Without the header, the same route answers 401 with
{"detail": "Authorization token is missing or invalid"}.
Through Swagger
Open http://127.0.0.1:8000/docs. POST /auth/signup returns the
activation_url; paste the token into POST /auth/activate/{token};
copy the access_token from the response, click Authorize (the
HTTPBearer scheme the dependency registers) and paste it. From then on,
Try it out on /api/profile goes authenticated.
What about the mobile app?¶
The app has no web page to receive the link, but it can receive a deep
link. The template is a .format() string with no scheme validation, so
it takes your app's scheme:
signup then returns
"activation_url": "meuapp://ativar?token=f8vX7Qa…". The app opens from
that link, reads token from the query string and calls
POST /auth/activate/{token} — the JSON path from step 4, which already
returns the JWT pair. AUTH_BACKEND_LINKS can stay off here: the app
handles the link, not the backend.
The same cycle in a test, with the email captured¶
In a test you want the production path — the link in the email, not in
the body. FakeEmailUtils is an EmailUtils that keeps each message in
outbox instead of opening an SMTP connection:
# tests/test_auth_flow.py
import re
from collections.abc import AsyncIterator
import pytest
from fastapi import Depends, FastAPI
from httpx import ASGITransport, AsyncClient, Response
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AuthSettings,
AuthUserSchema,
BaseUserModel,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
)
from tempest_fastapi_sdk.testing.fakes import FakeEmailUtils
class UserModel(BaseUserModel):
"""The account table."""
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
@pytest.fixture
async def mailer() -> FakeEmailUtils:
"""Return a mailer whose messages land in `outbox`.
Returns:
FakeEmailUtils: The fake mailer.
"""
return FakeEmailUtils()
@pytest.fixture
async def client(mailer: FakeEmailUtils) -> AsyncIterator[AsyncClient]:
"""Serve the auth router over an in-memory SQLite database.
Args:
mailer (FakeEmailUtils): Where the activation email lands.
Yields:
AsyncClient: A client bound to the app, no socket involved.
"""
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables()
service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=AuthSettings(
AUTH_BACKEND_LINKS=True,
AUTH_ACTIVATION_URL_TEMPLATE="http://test/auth/activate/{token}",
),
jwt_settings=JWTSettings(),
email=mailer,
)
get_current_user = service.current_user_dependency()
app: FastAPI = FastAPI()
app.include_router(make_auth_router(service, session_factory=db.session_dependency))
@app.get("/api/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> AuthUserSchema:
"""Return the account that owns the bearer token.
Args:
current (UserModel): The user resolved from the token.
Returns:
AuthUserSchema: The public view of the account.
"""
return AuthUserSchema.model_validate(current)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as opened:
yield opened
await db.disconnect()
async def test_signup_activate_login_profile(
client: AsyncClient,
mailer: FakeEmailUtils,
) -> None:
"""Walk the account from signup to a protected route.
Args:
client (AsyncClient): The app client.
mailer (FakeEmailUtils): The captured outbox.
"""
credentials: dict[str, str] = {"email": "ana@example.com", "password": "senha-forte-123"}
signup: Response = await client.post("/auth/signup", json=credentials)
assert signup.status_code == 201
assert signup.json()["activation_url"] is None
match = re.search(r"http://test/auth/activate/\S+", mailer.outbox[0].body)
assert match is not None
link: str = match.group(0)
page: Response = await client.get(link)
assert page.status_code == 200
assert "text/html" in page.headers["content-type"]
login: Response = await client.post("/auth/login", json=credentials)
assert login.status_code == 200
token: str = login.json()["access_token"]
profile: Response = await client.get(
"/api/profile",
headers={"Authorization": f"Bearer {token}"},
)
assert profile.status_code == 200
assert profile.json()["is_active"] is True
Note the assert signup.json()["activation_url"] is None: with an
EmailUtils configured, the link leaves the body and goes only to the
email — that is what proves the test exercises the production path. The
async fixtures need asyncio_mode = "auto" under
[tool.pytest.ini_options] in pyproject.toml (with pytest-asyncio
installed).
Installed only [auth]?
The JSON path works with [auth,sqlite]. With AUTH_BACKEND_LINKS=true
and without the [email] Jinja2, make_auth_router refuses to start and
names the missing extra — before, the router started, and the first click
on the link activated the account and answered 500.
Recap.
- With no frontend, the link comes in the
signupbody whileemail=None; with real email, only in the email. POST /auth/activate/{token}activates and already returns the JWT pair;GETon the same path is the Mode E HTML page, which asks for a login afterwards.AUTH_ACTIVATION_URL_TEMPLATEpoints at whoever will open the link: the frontend, the backend (Mode E) or the app (deep link).- The
access_tokengoes inAuthorization: Bearer …on any route guarded bycurrent_user_dependency().
Re-send the activation¶
The activation email goes out once, at signup. When it does not
arrive — a spam filter, a bounced relay, a tab closed before the click —
the account has no way forward:
- signing up again answers 409, the email is already on file;
- logging in answers 401, because
is_activeis stillFalse; email-verify/requestexists for exactly this case, but it requires a bearer token — which only a successful login hands out.
That is a dead end nothing but database access could open.
POST /auth/activation/request is the way out, and it asks for no
credentials at all, by necessity:
curl -X POST localhost:8000/auth/activation/request \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
{
"message": "If the email matches a pending account, a link was sent.",
"activation_url": "http://localhost:3000/activate?token=..."
}
activation_url shows up only when you deliver the link yourself —
AUTH_RETURN_TOKEN_IN_RESPONSE=True, or no EmailUtils wired. In
production it comes back null and the link travels by email, in whatever
language i18n resolves.
Always 202, always the same sentence
An unknown address, an already active account and a real re-send
answer with the same status and the same body. It is the same
anti-enumeration care as password-reset/request: probing the route
tells nobody which emails have an account here.
An active account is not emailed
Re-issuing for someone who already activated would send a link that authorizes nothing — and would tell a stranger that the address has an account. So the path stops there, quietly.
The new token follows the original's rules: it lives for
AUTH_ACTIVATION_TTL_SECONDS and, with AUTH_SINGLE_ACTIVE_TOKEN=True
(the default), the previous request is burned — only the newest link works.
Password recovery¶
The classic "I forgot my password". The user is not logged in and proves identity with a single-use token delivered by email. Two steps.
Base case (SPA / JSON)¶
Step 1 — request the link. The user types their email; the backend always answers 202 with the same generic message (it never leaks whether the email exists):
curl -X POST localhost:8000/auth/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
Step 2 — set the new password. The user opens the emailed link; the
front-end reads the token from the URL and sends it with the new
password. On success the password is replaced and the user is logged
in (a JWT pair comes back):
curl -X POST localhost:8000/auth/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{"token": "abc123…", "new_password": "new-strong-pass-12"}'
{
"user_id": "7d8e4d5a-…",
"access_token": "eyJhbGciOi…",
"refresh_token": "eyJhbGciOi…",
"mfa_required": false,
"mfa_token": null
}
An account with MFA ends the reset at /auth/mfa/verify
When the account has TOTP enrolled (and AUTH_MFA_ENABLED=True), the
confirm does not return the pair — it returns mfa_required=true
+ mfa_token, exactly like /login. That is what keeps a compromised
mailbox from skipping the second factor: the password is replaced, but
the session is only born after the code. Finish the login by sending
the mfa_token and the TOTP code:
curl -X POST localhost:8000/auth/mfa/verify \
-H "Content-Type: application/json" \
-d '{"mfa_token": "eyJ…", "code": "123456"}'
The pair that comes back carries amr == ["email", "otp"]; without MFA,
the pair from confirm itself carries ["email"] (v0.314.0+). See the
MFA recipe.
The reset drops sessions already open
Rotating a credential ends what was opened with the old one: changing
the password this way revokes the refresh tokens that already
existed, in the same transaction that writes the hash — a session
opened before the reset can no longer exchange its refresh token.
(Access tokens already issued live until their short TTL.) This is
the same default as /auth/mfa/confirm and tempest user
set-password; a caller that passes revoke_sessions=False to the
service gives that up on purpose.
Errors you'll see
- Unknown / already-used / expired token → 401 with
code: "INVALID_TOKEN"and adetailnaming the reason (unknown token:token not recognized). - A
tokenshorter than 16 characters → 422 from FastAPI itself (string_too_short), before it reaches the service. new_passwordviolatesAUTH_PASSWORD_MIN_LENGTH/ complexity → 422.requestnever errors on a missing email — always 202.
Dev mode: link in the body (no SMTP)
Without the [email] extra, or with AUTH_RETURN_TOKEN_IN_RESPONSE=True,
the reset_url comes back in the request body instead of by
email — you can drive the whole flow with no inbox. In production it
stays null.
Accounts that do not use emailed links (v0.316.0+)
UserAuthService(refuses_email_link=...) closes this flow (and the
activation one) for accounts chosen by a predicate — an operator, a
staff member. The request answers the same 202 and sends no email;
confirmation returns 403 EMAIL_LINK_FORBIDDEN without changing the
password; and signup creates the account inactive without persisting
or sending an activation link, with the same response as an ordinary
pending signup. See the MFA recipe.
No front-end? Backend-rendered HTML pages (Mode E)
With AUTH_BACKEND_LINKS=True the SDK mounts GET/POST
/auth/password-reset/{token}: the emailed link points at the
backend, which renders an HTML form, validates it and shows a
success/error page — no front-end route at all. See
Five operating modes (Mode E).
Change your password (logged in)¶
Different from a reset: here the user remembers their password and
is logged in. No token, no email — they send the access_token in
the header and re-confirm their current_password:
curl -X POST localhost:8000/auth/password-change \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "Content-Type: application/json" \
-d '{"current_password": "current-pass", "new_password": "new-strong-pass-12"}'
Returns 204; current tokens stay valid (this endpoint doesn't revoke
sessions). Wrong current_password → 401.
Reset vs change — which is which?
- Forgot the password, not logged in → Password recovery (
password-reset/request→confirm, prove with an emailed token). - Remembers the password, logged in → change password (
password-change, prove with the current password in the header).
Email change and recovery¶
The mirror of the password flow, but for the account email (v0.92.0+). Three distinct cases — start at yours.
Change email (logged in)¶
The base case: the user is logged in and wants to change their own
email. They confirm current_password and supply new_email; a
confirmation link goes to the new address. The email only changes
once that link is confirmed.
# Step 1 — request the change (authenticated). Always 202.
curl -X POST localhost:8000/auth/email-change/request \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "Content-Type: application/json" \
-d '{"new_email": "new@example.com", "current_password": "current-pass"}'
# Step 2 — confirm (the front reads the token from the link sent to the new email).
curl -X POST localhost:8000/auth/email-change/confirm \
-H "Content-Type: application/json" \
-d '{"token": "abc123…"}'
On confirmation, if AUTH_EMAIL_CHANGE_NOTIFY_OLD=True (default) a
security notice goes to the old address — the banks/Google pattern,
so a hijacked account still alerts its owner.
Errors
- Wrong current password → 401.
new_emailalready in use (at request or confirm — a race) → 409.- Invalid / expired / used token → 401 with
code: "INVALID_TOKEN".
Re-verify the current email¶
Re-sends a verification link to the current address — useful when the activation email was lost. Authenticated, changes nothing; confirming the link marks the account active.
curl -X POST localhost:8000/auth/email-verify/request \
-H "Authorization: Bearer eyJhbGciOi…"
# then: POST /auth/email-verify/confirm {"token": "…"}
Email recovery¶
The "I forgot / lost access to my email". An unauthenticated and
opt-in endpoint — only mounted with AUTH_EMAIL_RECOVERY_ENABLED=True.
The user proves identity with their password (and an MFA code when
TOTP is enrolled) and supplies the new address; the confirmation link goes
to the new email and reuses the same email-change/confirm.
# Only exists if AUTH_EMAIL_RECOVERY_ENABLED=True. Always 202 (anti-enumeration).
curl -X POST localhost:8000/auth/email-recovery/request \
-H "Content-Type: application/json" \
-d '{
"email": "old@example.com",
"new_email": "new@example.com",
"current_password": "current-pass",
"mfa_code": "123456"
}'
# then: POST /auth/email-change/confirm {"token": "…"} (link went to the new email)
Recovery is sensitive — enable it deliberately
This endpoint lets whoever has the password move the account to another email without accessing the old mailbox — an account-takeover vector if the password leaked. Rules:
- Opt-in: off until
AUTH_EMAIL_RECOVERY_ENABLED=True. - Always a generic 202 for every soft failure (unknown email,
wrong password, missing/invalid
mfa_code) — no account enumeration. - When TOTP is enrolled, a valid
mfa_codeis required. - Keep
AUTH_EMAIL_CHANGE_NOTIFY_OLD=Trueso the old email is always alerted of the change.
Where the new email is stored
The pending address travels in the token's payload column
(EMAIL_CHANGE), not on the UserModel. Confirmation re-reads the
payload, re-checks the address is still free (a request→confirm race
→ 409) and only then writes it. See the 0.92.0 migration note.
Renewing the session with the refresh token¶
The access_token is short-lived by design (JWT_ACCESS_TTL_SECONDS,
1 h default). When it expires, don't force the user to log in again —
exchange the refresh_token (long-lived, 7 days default) for a fresh
pair at POST /auth/refresh:
curl -X POST localhost:8000/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGciOi…"}'
{
"user_id": "7d8e4d5a-9f4b-4c3a-bd0a-1234567890ab",
"access_token": "eyJhbGciOi…(new)",
"refresh_token": "eyJhbGciOi…(new)",
"mfa_required": false,
"mfa_token": null
}
The endpoint decodes the token, requires it to actually carry the
refresh claim (a replayed access token is rejected with 401),
resolves the sub to an active user and mints a new pair.
Both tokens rotate
The response carries a new refresh_token. Persist that one and
discard the token you sent. In stateless mode (the default) the
SDK issues JWTs, so the old pair is not revoked — it stays valid
until its own exp.
Need real revocation? The SDK already ships it
Don't write your own refresh-token table — since v0.66.0 the SDK
offers opaque DB-backed refresh tokens with single-use rotation,
reuse detection (a stolen token revokes the whole family) and
POST /auth/logout. It is opt-in: pass a refresh_token_model to
UserAuthService. See the
Refresh tokens (rotation/revocation) recipe.
When the refresh token expires too
Then no renewal is possible — the 401 is final and the client
falls back to POST /auth/login with email + password.
No frontend? The service method behind the endpoint is public —
await service.refresh_tokens(session, refresh_token=...) returns
(user, access_token, refresh_token).
Settings — environment variables¶
Every knob in the flow comes from settings mixins. Mix them into your Settings class:
# src/core/settings.py
from tempest_fastapi_sdk import (
AuthSettings,
BaseAppSettings,
DatabaseSettings,
EmailSettings,
JWTSettings,
ServerSettings,
)
class Settings(
ServerSettings,
DatabaseSettings,
EmailSettings,
JWTSettings,
AuthSettings,
BaseAppSettings,
):
pass
settings: Settings = Settings()
The attribute name is the env var name
Every attribute in the tables below is read from an environment variable of the same name, case-sensitive, no prefix. AUTH_PASSWORD_MIN_LENGTH in .env → settings.AUTH_PASSWORD_MIN_LENGTH. They all have defaults — you only set what you want to change.
The variables split across two mixins and six concern groups. They're separated on purpose: a password is not the same thing as an email, and authentication (JWT) is not the same thing as account activation.
Group 1 — Authentication / JWT (JWTSettings)¶
Controls the signing and lifetime of the tokens login returns. It's the same JWT_SECRET the get_current_user dependency uses to verify (see Getting the current_user).
| Env var | Type | Default | What it does |
|---|---|---|---|
JWT_SECRET |
str (≥32 bytes) |
change-me-…-32 |
HMAC secret that signs the JWT. Must be overridden in production. |
JWT_ALGORITHM |
str |
HS256 |
JOSE algorithm. HS256/HS512 (symmetric secret) or RS256 (key pair). |
JWT_ACCESS_TTL_SECONDS |
int (≥1) |
3600 |
Access token lifetime (1 h). Short by design — renew via refresh. |
JWT_REFRESH_TTL_SECONDS |
int (≥1) |
604800 |
Refresh token lifetime (7 days). |
JWT_ISSUER |
str | None |
None |
iss claim. None omits the claim. |
The default JWT_SECRET leaks tokens
The default change-me-change-me-change-me-32 exists only to boot locally. In production, anyone with the default can forge a valid JWT. Generate a strong secret (openssl rand -base64 48) and inject it via a secret manager — never commit it.
Group 2 — Password policy (AuthSettings)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_PASSWORD_MIN_LENGTH |
int (≥1) |
12 |
Minimum length accepted on signup and reset. |
AUTH_PASSWORD_MAX_BYTES |
int (≥1) |
72 |
Maximum length in UTF-8 bytes. This is bcrypt's hard limit. |
AUTH_PASSWORD_REQUIRE_COMPLEXITY |
bool |
false |
true = require 1 lowercase + 1 uppercase + 1 digit + 1 special character. |
These two interact — this is where it usually gets confusing. The exact rule:
complexity=false(default): only length matters. Any password with≥ AUTH_PASSWORD_MIN_LENGTHcharacters passes, with no composition requirement.complexity=true: on top of the 4 character classes, the effective length floor becomesmax(AUTH_PASSWORD_MIN_LENGTH, 8). That is, anAUTH_PASSWORD_MIN_LENGTHbelow 8 is ignored while complexity is on.
Decision table:
MIN_LENGTH |
REQUIRE_COMPLEXITY |
Password accepted when |
|---|---|---|
12 |
false |
≥ 12 chars, any composition |
4 |
false |
≥ 4 chars, any composition (low floor, dev-only) |
4 |
true |
≥ 8 chars (floor 8 overrides the 4) + the 4 classes |
16 |
true |
≥ 16 chars + the 4 classes |
The floor is the single source of truth
The request schemas (SignupSchema, PasswordResetConfirmSchema) impose no length bound of their own — they delegate to these two vars. Lowering AUTH_PASSWORD_MIN_LENGTH to 4 genuinely relaxes validation on the route too. There is no hidden second limit in the schema "protecting" you.
The ceiling counts bytes, not characters
AUTH_PASSWORD_MAX_BYTES exists because bcrypt refuses input over 72 bytes: hashpw raises ValueError, and without the ceiling that surfaced as a 500 on signup / reset / password change. Bytes is the unit the hash sees, and 72 bytes arrive well before 72 characters on non-ASCII text — an emoji costs 4 bytes, an accented letter 2, so "🔒" * 19 (19 characters) is already over the limit and answers 422. Only raise the value if you swap the hasher for one without the limit.
Each violation has its own message_key (v0.305.0+)
The response code stays VALIDATION_ERROR, but the message_key names
the rule that failed, and the default catalog translates each one:
message_key |
When | detail in en-US (default catalog) |
|---|---|---|
PASSWORD_TOO_SHORT |
below the effective floor | Password must be at least 12 characters |
PASSWORD_TOO_LONG |
above AUTH_PASSWORD_MAX_BYTES |
Password must be at most 72 bytes |
PASSWORD_TOO_WEAK |
missing a character class (complexity on) | Password must contain a lowercase letter, an uppercase letter, a digit and a special character |
The same value is on PasswordPolicyViolation.code
(PasswordViolationCode), for callers of check_password_policy. To
change the code too, see
your product's error codes.
Group 3 — Email flow control (AuthSettings)¶
Decide whether and how the link reaches the user. They map directly to the five operating modes.
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_AUTO_ACTIVATE |
bool |
false |
true = user is born active, skips activation, signup returns the JWT pair directly (Mode D). Never in production. |
AUTH_RETURN_TOKEN_IN_RESPONSE |
bool |
false |
true = activation/reset link goes in the JSON body instead of the email (Mode C). |
Group 4 — Account token TTL (AuthSettings)¶
Lifetime of the single-use tokens (activation / reset) — distinct from the Group 1 JWTs.
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_ACTIVATION_TTL_SECONDS |
int (≥60) |
604800 |
Activation token lifetime (7 days). |
AUTH_PASSWORD_RESET_TTL_SECONDS |
int (≥60) |
3600 |
Reset token lifetime (1 h). Shorter is safer. |
Group 5 — Email URLs and templates (AuthSettings)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_ACTIVATION_URL_TEMPLATE |
str |
http://localhost:3000/activate?token={token} |
Activation link that goes in the email (or in the signup body, when there is no email); {token} is substituted. Points at whoever will open the link — frontend, backend or app. Per-mode table right below. |
AUTH_PASSWORD_RESET_URL_TEMPLATE |
str |
http://localhost:3000/reset-password?token={token} |
Same, for reset. |
AUTH_ACTIVATION_TEMPLATE |
str |
activation.html |
Jinja2 filename of the activation email HTML, resolved against EmailUtils.template_dir. |
AUTH_PASSWORD_RESET_TEMPLATE |
str |
password_reset.html |
Same, for reset. |
Where AUTH_ACTIVATION_URL_TEMPLATE points¶
The template is just a .format() string: the SDK substitutes {token} and
does not know who will open the result. The mode decides:
| Mode | Who opens the link | Template | Route that consumes the token |
|---|---|---|---|
| A, B, C | a frontend page, which reads ?token= and calls the API |
https://app.example.com/activate?token={token} |
POST /auth/activate/{token} |
| E — backend-only | the backend itself: AUTH_BACKEND_LINKS=true mounts an HTML page at GET /auth/activate/{token} that activates the account on click |
https://api.example.com/auth/activate/{token} (with the router prefix, if any) |
GET /auth/activate/{token} |
| Mobile app | the app, through a deep link | meuapp://ativar?token={token} |
POST /auth/activate/{token} |
| D | nobody — the account is born active | irrelevant | none |
Dev without a frontend: the default points at localhost:3000, a page
that does not exist in your case. Turn on Mode E with the template on the
backend itself (http://127.0.0.1:8000/auth/activate/{token}), or leave the
template as is and send the token to POST /auth/activate/{token}. The step
by step, with measured responses, is in
Backend only.
URL template ≠ Jinja2 template
*_URL_TEMPLATE is a .format() string with {token} — it's the link. *_TEMPLATE is the name of an .html file — it's the email that wraps the link. Confusing the two is the #1 mistake. Full detail in Email anatomy.
Group 6 — Backend-rendered pages (Mode E, AuthSettings)¶
Only relevant when AUTH_BACKEND_LINKS=true. See Mode E for the full flow.
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_BACKEND_LINKS |
bool |
false |
true = mounts 5 extra HTML endpoints; the email link points at the backend, not the frontend. |
AUTH_LOGIN_URL |
str | None |
None |
Login URL on the "go to login" button of success pages. None hides the button. |
AUTH_ACTIVATION_SUCCESS_TEMPLATE |
str |
activation_success.html |
Activation OK HTML page. |
AUTH_ACTIVATION_ERROR_TEMPLATE |
str |
activation_error.html |
Activation error HTML page. |
AUTH_PASSWORD_RESET_FORM_TEMPLATE |
str |
password_reset_form.html |
New-password HTML form. |
AUTH_PASSWORD_RESET_SUCCESS_TEMPLATE |
str |
password_reset_success.html |
Reset OK HTML page. |
AUTH_PASSWORD_RESET_ERROR_TEMPLATE |
str |
password_reset_error.html |
Reset error HTML page. |
Group 7 — Language of emails and pages (AuthSettings)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_DEFAULT_LOCALE |
str |
pt-BR |
Language of the bundled emails and HTML pages when no other signal exists. Accepts pt-BR and en-US (normalized: PT-BR, pt_br, ptbr → pt-BR). |
AUTH_STAMP_LOCALE_IN_LINK |
bool |
True |
Stamps ?lang=<locale> onto the emailed link, so the page opens in the language of the email that produced it. |
There's a whole section dedicated to this, explained step by step: Email and page language (i18n).
Group 8 — Token delivery: bearer / cookie / both (AuthSettings) (v0.87.0+)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_TOKEN_DELIVERY |
"bearer" | "cookie" | "both" |
bearer |
How login/refresh return the JWT pair. See Token delivery. |
AUTH_COOKIE_SECURE |
bool |
true |
Flag cookies as Secure (HTTPS only). Turn off only on plain HTTP — otherwise the browser drops the cookie. |
AUTH_COOKIE_SAMESITE |
"lax" | "strict" | "none" |
lax |
A cross-site SPA needs none (+ Secure=true). |
AUTH_COOKIE_DOMAIN |
str | None |
None |
Cookie Domain. None = exact host. Use .example.com to share across subdomains. |
AUTH_ACCESS_COOKIE_NAME |
str |
access_token |
Access-token cookie name. |
AUTH_REFRESH_COOKIE_NAME |
str |
refresh_token |
Refresh-token cookie name (scoped to the refresh endpoint path). |
Group 9 — Closed system (AuthSettings) (v0.272.0+)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_SIGNUP_ENABLED |
bool |
true |
false = make_auth_router does not mount POST /auth/signup; the route is gone from the application and from the OpenAPI schema. Activation, reset and everything else stay intact. See Closed system. |
Group 10 — Social login (AuthSettings + OAuthSettings) (v0.273.0+)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_OAUTH_ENABLED |
bool |
false |
true mounts the five /auth/oauth/* routes ({provider}/login, {provider}/callback, {provider}/token, accounts and accounts/unlink). Requires a client in oauth_clients=, an oauth_account_model on the service and a name column on the user model — each missing piece raises RuntimeError at router construction. |
AUTH_OAUTH_STATE_COOKIE_NAME |
str |
oauth_state |
Cookie carrying the CSRF state between the redirect and the callback. Always HttpOnly and always SameSite=Lax. |
AUTH_OAUTH_STATE_TTL_SECONDS |
int |
600 |
How long the user has to finish consenting at the provider. |
AUTH_OAUTH_LINK_BY_VERIFIED_EMAIL |
bool |
false |
true attaches a new identity to an existing account whose email matches — only when email_verified is True. Off by default because this is the knob that turns a provider's word about an email into control of an account. |
AUTH_OAUTH_ALLOW_ACCOUNT_CREATION |
bool or empty |
empty | Whether the callback may create an account. Empty inherits AUTH_SIGNUP_ENABLED, so closing signup closes this door with it. |
OAUTH_REDIRECT_BASE_URL |
str |
"" |
Public origin of the service. The redirect URI is derived from it by oauth_redirect_uri(provider). |
OAUTH_GOOGLE_CLIENT_ID / OAUTH_GOOGLE_CLIENT_SECRET |
str |
"" |
Credentials from the Google console. google_kwargs() builds the client. |
OAUTH_GOOGLE_EXTRA_AUDIENCES |
list[str] |
[] |
This project's other client ids (Android, iOS). Without them every mobile login through POST /auth/oauth/google/token is refused as another application's token. |
OAUTH_GITHUB_CLIENT_ID / OAUTH_GITHUB_CLIENT_SECRET |
str |
"" |
Credentials of the GitHub OAuth app. github_kwargs() builds the client. |
Group 11 — One link at a time (AuthSettings) (v0.274.0+)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_SINGLE_ACTIVE_TOKEN |
bool |
true |
Issuing an account token spends that user's other unused tokens of the same purpose, so only the newest activation / reset / email-change link opens the account. false restores the pre-v0.274.0 behaviour. See the migration guide. |
Without this, the user's correct reaction does not close the window
An attacker fires POST /auth/password-reset/request at a victim. The
victim receives a recovery email they never asked for, gets suspicious,
and on their own requests a reset and completes it — exactly the right
reaction. With several links alive, the attacker's link stays valid until
AUTH_PASSWORD_RESET_TTL_SECONDS, and a token leaked through any side
channel (a proxy log, a browser extension, a forwarded email, a shared
device) still resets the password after the incident looked handled.
The scope is narrow in both directions: same purpose only (requesting a
reset does not kill a pending email change) and same user only. The old row
is marked used_at, not deleted, so the audit trail stays.
Group 12 — Deactivated account (AuthSettings) (v0.305.0+)¶
| Env var | Type | Default | What it does |
|---|---|---|---|
AUTH_REVEAL_INACTIVE_ACCOUNT |
bool |
false |
true = the right password on a deactivated account answers 403 ACCOUNT_INACTIVE instead of the generic 401. A wrong password and an unknown email stay 401. |
By default a deactivated account answers the same 401 as a wrong password, so
the response does not tell which emails have an account. But the owner, not
knowing why they cannot get in, tries "forgot password", resets it, and still
cannot get in. Turned on, whoever proved the password is told — and that
enumerates nothing, because whoever has the password already owns the account:
$ curl -s -X POST localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"strong-pass-12-chars"}'
{"detail":"Esta conta está desativada — fale com o suporte para reativá-la","code":"ACCOUNT_INACTIVE","details":{}}
(With the default catalog in register_exception_handlers(app, catalog=default_message_catalog()),
which negotiates pt-BR here; without a catalog, detail is account is not active.)
The cost is the same in all three branches
The password is verified before is_active is read, on or off. An
unknown email pays one bcrypt against a throwaway hash. Measured in
tests/auth/test_refusal_codes.py: exactly one verification per refused
login, in all three branches, with the flag on and off.
MFA / TOTP has its own vars
When AUTH_MFA_ENABLED=true, AuthSettings also exposes AUTH_MFA_ISSUER, AUTH_MFA_RECOVERY_CODES_COUNT, AUTH_MFA_TOKEN_TTL_SECONDS and AUTH_MFA_VERIFY_WINDOW. They're out of scope for this recipe (signup/activate/login/reset) — covered in the MFA recipe.
Your product's error codes (AuthExceptions) (v0.305.0+)¶
An existing service has clients handling its own codes —
ERROR_USER_INVALID_CREDENTIALS, USER_ALREADY_EXISTS, INVALID_PASSWORD. The
SDK answers UNAUTHORIZED, CONFLICT, VALIDATION_ERROR, and the catalog
swaps the message, not the code. Without a seam, adopting
UserAuthService meant overriding methods only to swap the exception.
AuthExceptions is that seam: it says which class is raised at each refusal
point.
# src/services/auth.py
from tempest_fastapi_sdk import AuthExceptions, UserAuthService
from tempest_fastapi_sdk.exceptions import (
ConflictException,
ForbiddenException,
InvalidTokenException,
UnauthorizedException,
ValidationException,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
class InvalidCredentialsError(UnauthorizedException):
"""Wrong email or password."""
code: str = "ERROR_USER_INVALID_CREDENTIALS"
class UserDeactivatedError(ForbiddenException):
"""Deactivated account, revealed to whoever proved the password."""
code: str = "USER_ACCOUNT_DEACTIVATED"
class UserAlreadyExistsError(ConflictException):
"""Email already registered."""
code: str = "USER_ALREADY_EXISTS"
class InvalidPasswordError(ValidationException):
"""Short or weak password."""
code: str = "INVALID_PASSWORD"
class PasswordTooLongError(ValidationException):
"""Password above bcrypt's limit."""
code: str = "PASSWORD_TOO_LONG"
class InvalidResetTokenError(InvalidTokenException):
"""A single-use link that does not work."""
code: str = "INVALID_PASSWORD_RESET_TOKEN"
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
exceptions=AuthExceptions(
invalid_credentials=InvalidCredentialsError,
account_inactive=UserDeactivatedError,
email_taken=UserAlreadyExistsError,
password_too_short=InvalidPasswordError,
password_too_long=PasswordTooLongError,
password_too_weak=InvalidPasswordError,
invalid_token=InvalidResetTokenError,
),
)
Measured with register_exception_handlers(app, catalog=default_message_catalog())
and AUTH_REVEAL_INACTIVE_ACCOUNT=true:
| Request | Without exceptions= |
With the map above |
|---|---|---|
| login, wrong password | 401 UNAUTHORIZED |
401 ERROR_USER_INVALID_CREDENTIALS |
| login, deactivated account, right password | 403 ACCOUNT_INACTIVE |
403 USER_ACCOUNT_DEACTIVATED |
| signup, short password | 422 VALIDATION_ERROR |
422 INVALID_PASSWORD |
| signup, 73 bytes | 422 VALIDATION_ERROR |
422 PASSWORD_TOO_LONG |
| signup, repeated email | 409 CONFLICT |
409 USER_ALREADY_EXISTS |
Each field is typed with the default class, so yours must subclass it.
That is what holds the status: an InvalidCredentialsError is an
UnauthorizedException, so it stays 401, and the refusal path — including
login's single bcrypt — is the same. The class changes, and the code with it.
The message for your code
For the password violations and the deactivated account the SDK passes
its own message_key (PASSWORD_TOO_SHORT, ACCOUNT_INACTIVE, …), so
detail stays translated with your class. Elsewhere the catalog looks up
your code; without a translation for it, detail is the SDK's
English message (invalid email or password). Add yours with
default_message_catalog().merge({"en-US": {"ERROR_USER_INVALID_CREDENTIALS": "..."}}).
What invalid_token covers
The single-use links: activation, password reset, email change and
verification — unknown, used, expired, or pointing to a user that no
longer exists. Since v0.305.0 a password reset whose user was removed is
an InvalidTokenException like its siblings, no longer a 404. Refresh
tokens are left out: they are a different contract with the client.
Recap. AuthExceptions swaps the class — and the code — at each refusal
point, without overrides and without changing status or cost. Password
violations already carry their own message_key, and
AUTH_REVEAL_INACTIVE_ACCOUNT shows the 403 only to whoever proved the
password.
Email anatomy¶
Three different concepts that look the same. Here's what each one does, exactly once, in pseudo-code:
1. SDK generates a random opaque token (64-char string).
2. AUTH_ACTIVATION_URL_TEMPLATE.format(token=…) → link with the token embedded.
3. Renders AUTH_ACTIVATION_TEMPLATE (Jinja2 HTML) passing { user, activation_url, expires_at, expires_at_str }.
4. EmailUtils.send(to=user.email, subject=..., html=<rendered HTML>).
In prose:
- Opaque token — random string the SDK generates, hashes (SHA-256), and stores in the
user_tokenstable. The plaintext leaves over email only once; the database keeps just the hash. - URL template (
AUTH_ACTIVATION_URL_TEMPLATE) — literal format string used to build the URL the user will click. In modes A–C it points at the frontend: it reads?token=…from the query string and callsPOST /auth/activate/{token}on the backend. In Mode E it points at the backend, which activates the account onGET. Full table in Where the template points. - Jinja2 template (
AUTH_ACTIVATION_TEMPLATE) — filename of the HTML template insideEmailUtils.template_dir. It's the HTML body of the email, not the URL. It receives the{ user, activation_url, expires_at, expires_at_str }context and renders the final markup. Use{{ expires_at_str }}in the template — it's the expiry already formatted short (e.g.2026-06-21 23:25 (UTC), no seconds);expires_atis still available as the rawdatetimeif you want to format it yourself.
URL template ≠ Jinja2 template
AUTH_ACTIVATION_URL_TEMPLATE is a Python .format()-style string with just the {token} placeholder. Don't confuse it with the .html file Jinja2 renders. The formatted URL is injected as a variable into the Jinja2 context under the name activation_url, and the HTML template wraps it in a button.
Visual flow:
sequenceDiagram
participant U as User
participant F as Frontend
participant API as Backend (SDK)
participant E as SMTP
participant DB as Database
U->>F: fills email + password
F->>API: POST /auth/signup
API->>DB: INSERT user (is_active=false) + INSERT token (hash, TTL)
API->>API: token plaintext + AUTH_ACTIVATION_URL_TEMPLATE.format(token=…)
API->>API: render Jinja2 (user, activation_url, expires_at)
alt AUTH_RETURN_TOKEN_IN_RESPONSE=false
API->>E: SMTP send (rendered HTML)
API->>F: 201 + {activation_required: true, activation_url: null}
else AUTH_RETURN_TOKEN_IN_RESPONSE=true
API->>F: 201 + {activation_required: true, activation_url: "https://app/activate?token=..."}
end
Note right of F: user (or dev) opens the URL
F->>API: POST /auth/activate/{token}
API->>DB: hash(token) match? expired? already used?
API->>F: 200 + JWT pair
Email and page language (i18n)¶
Since v0.59.0, the emails and HTML pages the SDK ships out of the box
speak two languages: 🇧🇷 Brazilian Portuguese (pt-BR) — which is
the default — and 🇺🇸 US English (en-US). You don't need to
create any template for this to work. 🚀
The golden rule (memorize just this)¶
One flow, one language. The email and the page that email opens resolve the language the same way, in the same order — the first signal that exists wins:
| # | Signal | Where it comes from |
|---|---|---|
| 1 | ?lang= on the link |
The SDK stamps the language that email went out in onto its own link. |
| 2 | user.locale |
The preference you stored on the user row (the LocaleColumnMixin column). It is what the email resolves from — there is no link yet when it is built. |
| 3 | Accept-Language |
The browser of whoever clicked (pages only — an email has no browser). |
| 4 | AUTH_DEFAULT_LOCALE |
The last resort. |
Why does the link outrank the stored preference?
Because it records the language of that email. If the person changes their language in the app between the send and the click, the page still matches the message they are reading. Preferring the row would recreate the very split this order exists to close, only inverted.
Through v0.263.0 the two ends disagreed
The email read only AUTH_DEFAULT_LOCALE and the page negotiated
only Accept-Language. A signup with the pt-BR default and an
English browser sent the email in Portuguese and opened the page in
English — and no configuration fixed it, because setting
AUTH_DEFAULT_LOCALE=pt-BR was exactly the value the header
overrode. If you store the user's language, it now drives both ends.
Why does the link need to carry the language?
At signup the account was just born: there's no stored preference
yet. The only place the email's choice survives until the click is
the link itself — hence ?lang=. Turn it off with
AUTH_STAMP_LOCALE_IN_LINK=false if your front-end route rejects
unknown query parameters.
Step 1 — choose the default language¶
Just one environment variable. That's it:
Want everything in English? Switch to:
You don't need the exact case/format
The value is normalized for you. All of these become pt-BR:
pt-BR, PT-BR, pt_br, ptbr, pt. And all of these become
en-US: en-US, EN_us, enus, en. If you type something the
SDK doesn't know (like klingon), it falls back to the pt-BR
default instead of crashing.
Step 2 — (optional) store the user's preference¶
If your service lets people pick a language, store it on their row with
LocaleColumnMixin — and the SDK honors it on both ends, with no further
configuration:
from tempest_fastapi_sdk import BaseUserModel, LocaleColumnMixin
class UserModel(LocaleColumnMixin, BaseUserModel):
"""The BCP-47 `locale` column comes from the mixin."""
__tablename__ = "users"
Nothing breaks without the mixin: the SDK reads the attribute with
getattr, so a model without the column simply falls through to the next
signal.
user.locale = "pt-BR", en-US browser → email AND page in Portuguese
link says ?lang=pt-BR, row says en-US → page in Portuguese (matches the email)
neither, en-US browser → page in English
none of the above → AUTH_DEFAULT_LOCALE
Step 3 — (optional) translate/customize it yourself¶
The bundled templates live in per-language subfolders (pt-BR/,
en-US/). To change the text/look of just one language, drop a file
with the same name into the right subfolder of your template_dir
(e.g. template_dir/en-US/activation_success.html). The full lookup
order is in the "Override per language" tip further down, under
Mode E.
Bonus — short, readable expiry timestamp¶
The email used to show the raw, ugly expiry like this:
Now the SDK injects an expires_at_str variable into the template,
already formatted and without seconds, in the language's format:
| Language | How it looks |
|---|---|
pt-BR |
21/06/2026 23:25 (UTC) |
en-US |
2026-06-21 23:25 (UTC) |
In your custom templates, use {{ expires_at_str }} (short and pretty).
If you want to format it yourself, the raw datetime is still available
in {{ expires_at }}.
Recap
- One flow, one language: email and page resolve in the same
order — the link's
?lang=→user.locale→Accept-Language→AUTH_DEFAULT_LOCALE. - Stored the user's language? It drives both ends.
- The default is
pt-BR. Seten-USif you want English. AUTH_STAMP_LOCALE_IN_LINK=falseturns the?lang=stamp off.- Use
{{ expires_at_str }}to show the expiry without seconds.
Five operating modes¶
| Mode | When to use | Flags | Where the link appears |
|---|---|---|---|
| A. Production (SPA) | Public SaaS, real email, frontend SPA owns the pages | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=falseAUTH_BACKEND_LINKS=falseReal SMTP (Mailgun, SES, Postmark…) |
The user's inbox → frontend processes the token |
| B. Local dev with fake SMTP | Daily development without sending real email | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=falseSMTP pointing at Mailhog ( localhost:1025) or smtp4dev (localhost:2525) |
Mailhog/smtp4dev web UI at localhost:8025 / localhost:5000 |
| C. Dev without SMTP | Quick validation without spinning up any email container | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=trueemail=None or invalid SMTP |
HTTP signup response body |
| D. CI / tests | Test suite that doesn't exercise activation | AUTH_AUTO_ACTIVATE=true |
Nowhere — signup returns the JWT pair directly |
| E. Backend-only (v0.32.0+) | You want 100% control on the backend — zero responsibility on the frontend. Ideal for APIs without a SPA, MVPs, internal tools. | AUTH_BACKEND_LINKS=trueURL templates point at the backend ( https://api.example.com/auth/activate/{token})AUTH_LOGIN_URL=https://app.example.com/login (optional — shows a "Go to login" button on the HTML pages) |
The backend renders HTML success/error directly — the user only clicks the link in the email |
Mode A — production¶
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USERNAME=postmaster@mg.example.com
SMTP_PASSWORD=... # secret, don't commit
SMTP_FROM_ADDR=noreply@example.com
AUTH_ACTIVATION_URL_TEMPLATE=https://app.example.com/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://app.example.com/reset?token={token}
Flow: signup → real email lands in the inbox → user clicks → frontend calls POST /auth/activate/{token} → login.
Mode B — dev with local SMTP (Mailhog or smtp4dev)¶
Same .env as mode A, but point SMTP at a local container that intercepts the emails instead of actually mailing them. Use this mode in day-to-day dev — the flow is identical to production, so you catch template bugs, encoding issues, charset problems, etc. while avoiding real-email spam.
# .env.dev
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=localhost
SMTP_PORT=1025 # Mailhog SMTP default
SMTP_USERNAME= # empty — Mailhog doesn't authenticate
SMTP_PASSWORD=
SMTP_FROM_ADDR=dev@local
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=http://localhost:5173/reset?token={token}
Open http://localhost:8025 (Mailhog) or http://localhost:5000 (smtp4dev) to inspect the intercepted emails. See Mailhog vs smtp4dev below.
Mode C — dev without SMTP (link in body)¶
No SMTP container at all. Signup returns the activation link in the JSON body:
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=true
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}
Request:
curl -X POST localhost:8000/auth/signup \
-H 'content-type: application/json' \
-d '{"email":"dev@local","password":"abcdefghijkl","name":"Dev"}'
Response (the actual SignupResponseSchema shape):
{
"user_id": "0193e9ea-7c4b-7c8e-bc05-2a3a8d9f7e10",
"activation_required": true,
"activation_url": "http://localhost:5173/activate?token=aBcD...xYz",
"access_token": null,
"refresh_token": null
}
Paste the URL into the browser / curl to exercise POST /auth/activate/{token}.
Mode D — CI / tests (skip everything)¶
Signup skips activation entirely and returns {access_token, refresh_token} straight away. Use only in tests or when the product is internal and every user is already trusted.
Mode E — backend-only (v0.32.0+)¶
When you'd rather have the whole link experience happen on the backend, with no frontend page in the loop, flip AUTH_BACKEND_LINKS=True. The router then mounts five extra HTML endpoints — GET /auth/activate/{token}, GET /auth/password-reset/{token}, POST /auth/password-reset/{token} (form-encoded), GET /auth/email-change/{token} and GET /auth/email-verify/{token}. The email points the user straight at those endpoints; the backend activates the account / processes the reset / renders HTML success or error — using bundled Jinja2 templates you can shadow.
It is also the mode for dev without a frontend: the whole cycle — signup, link, login and protected route — with measured responses is in Backend only.
# .env — Mode E (backend-only)
AUTH_BACKEND_LINKS=true
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
# IMPORTANT: URL templates point at the BACKEND, not the frontend.
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://api.example.com/auth/password-reset/{token}
# Optional: your login URL. When set, the backend-rendered success/error
# pages display a "Go to login" button. When null, the button is hidden
# (pure server-side, zero coupling with any frontend).
AUTH_LOGIN_URL=https://app.example.com/login
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_FROM_ADDR=noreply@example.com
Link returns 404? Align the template with the router's mount prefix
The endpoints above are relative to wherever you mount make_auth_router.
If you include the router under a prefix — common to separate business routes:
then the real activation route becomes GET /api/auth/activate/{token}, not
/auth/activate/{token}. But AUTH_ACTIVATION_URL_TEMPLATE is a literal string
— it has no idea about the prefix. If the template points at
.../auth/activate/{token} (without /api), the email link hits a
non-existent route and returns 404, even though signup returned 201.
# ❌ router mounted with prefix="/api", but the template lacks /api → 404
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}
# ✅ template aligned with the actual mount prefix
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/api/auth/activate/{token}
Two checks when configuring Mode E: (1) the host is the backend's
public domain (never localhost — the link runs in the user's browser,
not on the server); (2) the path includes every prefix you mounted the
router under.
Flow:
sequenceDiagram
participant U as User
participant E as Inbox
participant API as Backend
participant DB as Database
U->>API: POST /auth/signup
API->>DB: INSERT user (is_active=false) + token (hash, TTL)
API->>E: email with link https://api.example.com/auth/activate/{token}
U->>E: clicks the link
E->>API: GET /auth/activate/{token}
API->>DB: hash(token) valid? unused? not expired?
alt valid token
API->>DB: is_active=true + token.used_at=now
API->>U: HTML activation_success.html ("Go to login" button when AUTH_LOGIN_URL set)
else invalid / expired token
API->>U: HTML activation_error.html (HTTP 400)
end
Password reset follows the same pattern: GET renders an HTML form; POST (form-encoded) consumes the token and renders success/error.
Bundled HTML templates (shadow by dropping the same filename under template_dir):
| Template | Endpoint that renders it | Jinja2 variables exposed |
|---|---|---|
activation_success.html |
GET /auth/activate/{token} (success) |
user, login_url |
activation_error.html |
GET /auth/activate/{token} (failure) |
reason, login_url |
password_reset_form.html |
GET /auth/password-reset/{token} |
user, form_action, min_length, error, login_url |
password_reset_success.html |
POST /auth/password-reset/{token} (success) |
user, login_url |
password_reset_error.html |
POST /auth/password-reset/{token} (bad token) |
reason, login_url |
To override: pass template_dir to make_auth_router and add files with the same filenames.
from fastapi import FastAPI
from tempest_fastapi_sdk import UserAuthService, make_auth_router
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
)
app = FastAPI()
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
template_dir="src/templates/auth", # optional
),
)
Override per language (since v0.59.0)
The bundled templates now live in per-language subfolders
(pt-BR/ and en-US/). You have two ways to override, and the SDK
searches in this order (the first that exists wins):
template_dir/<locale>/activation_success.html— override just that language (e.g.src/templates/auth/pt-BR/...).template_dir/activation_success.html— flat override, applies to every language (backward compatible with pre-0.59.0; keeps working with no changes).
In short: if you already had flat templates, you don't need to change anything. If you want a different look per language, create the subfolder.
Mode E trade-offs:
- ✅ Zero frontend dependency — the backend is the single source of truth for the auth flow.
- ✅ MVP in minutes — no need to create SPA routes to process tokens.
- ✅ Works in frontend-less projects — public APIs, intranets, internal tooling.
- ⚠️ JWT is not auto-delivered — after activation, the user signs in manually (clicking "Go to login" and entering credentials). By design: zero token leak via URL, history, or server logs.
- ⚠️ Requires the
[email]extra (Jinja2) to render the HTML pages — same dependency as the email template renderer. Without it,make_auth_routerraisesRuntimeErrorat construction naming the extra, instead of the first click activating the account and answering 500. - ⚠️ No CSRF on the reset form — the HTML form posts traditionally without a CSRF token. The reset token is one-shot + short TTL + bound to a single user, but consider plugging in
CSRFMiddlewareif attackers can predict active URLs.
The JSON endpoints (POST /auth/activate/{token}, POST /auth/password-reset/confirm) are still mounted — you can mix Mode E with SPA endpoints.
Token delivery¶
(v0.87.0+)
By default login returns access_token / refresh_token in the body and the client replays them as Authorization: Bearer <token>. Great for mobile/API clients, but a browser SPA has to stash the token somewhere JS can reach — exposed to XSS. AUTH_TOKEN_DELIVERY lets you choose.
| Mode | What changes | For whom |
|---|---|---|
bearer (default) |
Tokens in the body only. Historical, backward-compatible behaviour. | Mobile, APIs, clients that send Authorization. |
cookie |
Tokens set as HttpOnly cookies on the same paths (/auth/login, /auth/refresh, /auth/logout); the body returns the tokens as null. |
Browser SPAs — the token is never visible to JS (XSS defense). |
both |
Bearer endpoints stay at /auth/* and a parallel cookie set is mounted at /auth/cookie/*. |
One backend serving web (cookie) and mobile (bearer) at once. |
The cookie Secure flag requires HTTPS
With AUTH_COOKIE_SECURE=true (default) the browser only sends the cookie back over HTTPS. If the backend is served over plain HTTP, the cookie is dropped and the session never persists (login looks like it works but nothing stays logged in). In production, put TLS in front and keep it true; on local HTTP dev use AUTH_COOKIE_SECURE=false.
Cookie mode¶
# .env
AUTH_TOKEN_DELIVERY=cookie
AUTH_COOKIE_SECURE=true # false only on plain HTTP
AUTH_COOKIE_SAMESITE=lax # "none" (+Secure) if the SPA is cross-site
from fastapi import FastAPI
from tempest_fastapi_sdk import UserAuthService, make_auth_router
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
)
app = FastAPI()
app.include_router(make_auth_router(auth_service, session_factory=db.session_dependency))
Frontend flow — stores no token at all, just calls the endpoints with credentials: "include":
// login: the browser stores the HttpOnly cookies itself
await fetch("/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
// authenticated requests: the access cookie rides along automatically
await fetch("/api/me", { credentials: "include" });
// renew the session: the refresh cookie is read on the backend, no body
await fetch("/auth/refresh", { method: "POST", credentials: "include" });
// logout: clears the cookies (and revokes the refresh family if a refresh_token_model is wired)
await fetch("/auth/logout", { method: "POST", credentials: "include" });
The current_user dependency (see Getting the current_user) now reads the access token from the cookie automatically whenever delivery involves cookies — the Authorization header still wins if present.
Both mode¶
Mounts both sets, no route collision:
# bearer (body):
POST /auth/login
POST /auth/refresh
POST /auth/logout
# cookie (HttpOnly):
POST /auth/cookie/login
POST /auth/cookie/refresh
POST /auth/cookie/logout
What stays in the body
Cookie delivery covers the login / refresh / logout lifecycle. Activation (POST /auth/activate/{token}), signup auto-login (AUTH_AUTO_ACTIVATE) and POST /auth/mfa/verify still return the JWT pair in the body, regardless of AUTH_TOKEN_DELIVERY.
Fine-tuning the cookies: cookie_config=
The AUTH_COOKIE_* settings cover the common case. When you need
per-attribute control — name, max_age, refresh path, Domain — pass an
AuthCookieConfig to make_auth_router; it wins over the settings:
from tempest_fastapi_sdk import AuthCookieConfig, make_auth_router
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
token_delivery="cookie",
cookie_config=AuthCookieConfig(
access_name="session",
refresh_name="session_refresh",
access_max_age=900,
refresh_max_age=1209600,
samesite="none",
secure=True,
domain=".example.com",
),
),
)
Mirror access_max_age on JWT_ACCESS_TTL_SECONDS and refresh_max_age on
JWT_REFRESH_TTL_SECONDS: a cookie outliving its token leaves the browser
sending a dead credential, and the reverse logs the user out early. The
router narrows refresh_path to the refresh endpoint, so the long-lived
token never rides on an ordinary call. http_only=True is the whole point
of cookie mode — do not turn it off.
Outside make_auth_router the same rules apply through
apply_auth_cookies(response, access_token=..., refresh_token=..., config=...)
and clear_auth_cookies(response, config=...) — handy in a login route of
your own (the OAuth callback in the social login recipe, say).
CORS with credentials
For a cross-origin SPA to send/receive cookies, the backend needs allow_credentials=True in CORS and AUTH_COOKIE_SAMESITE=none + AUTH_COOKIE_SECURE=true (hence HTTPS). Same-origin (frontend served from the API's domain) works with the default lax.
Mailhog vs smtp4dev¶
Both intercept local SMTP and render emails in a web UI. Relevant differences:
| Aspect | Mailhog | smtp4dev |
|---|---|---|
| Docker image | mailhog/mailhog:latest |
rnwood/smtp4dev:latest |
| Default SMTP port | 1025 |
2525 (configurable) |
| UI port | 8025 |
5000 |
| Image size | ~10 MB | ~120 MB (.NET) |
| Multi-account / multi-inbox | no — single mailbox | yes — filters by recipient |
| HTTP / REST API | yes (/api/v2/messages) |
yes (built-in Swagger) |
| DKIM / SPF validation | no | yes |
| Upstream maintenance | archived in 2020, still works | active |
Suggestion: start with Mailhog (lighter, zero-config) and switch to smtp4dev when you need multi-inbox or DKIM inspection. For the signup → activate → reset cycle, Mailhog is enough.
docker-compose.yaml — Mailhog¶
services:
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog
ports:
- "1025:1025" # SMTP — point SMTP_HOST here
- "8025:8025" # web UI
SMTP_PORT=1025, open http://localhost:8025.
docker-compose.yaml — smtp4dev¶
services:
smtp4dev:
image: rnwood/smtp4dev:latest
container_name: smtp4dev
ports:
- "2525:25" # SMTP — point SMTP_HOST here
- "5000:80" # web UI
environment:
- ServerOptions__HostName=smtp4dev
SMTP_PORT=2525, open http://localhost:5000.
tempest generate --docker already includes Mailhog
When the project pins the [email] extra, tempest generate --docker auto-appends a Mailhog service to the docker-compose.yaml — no manual paste and no --with mailhog flag. The blocks above are just a reference, or for when you prefer smtp4dev.
Customizing templates¶
The SDK ships two bundled Jinja2 templates (activation.html + password_reset.html) — responsive HTML, inline styles, mobile-friendly. You never need to touch them for an MVP to work. When you want your own branding, drop a file with the same name into the template_dir you passed to EmailUtils:
emails/ # ← template_dir="emails"
├── activation.html # overrides the SDK default
└── password_reset.html # overrides the SDK default
EmailUtils uses a ChoiceLoader internally so Jinja2 looks first in your directory and only falls back to the bundled template if it can't find yours. Override one, the other, or both — no need to copy the entire template.
Variables available in the Jinja2 context¶
| Variable | Type | In which templates | Example |
|---|---|---|---|
user |
UserModel instance |
both | {{ user.email }}, {{ user.name }} (when your model exposes the column) |
activation_url |
str |
activation.html |
https://app.example.com/activate?token=aBcD...xYz |
reset_url |
str |
password_reset.html |
https://app.example.com/reset?token=aBcD...xYz |
expires_at |
datetime (UTC, timezone-aware) |
both | the raw value, if you want to format it yourself |
expires_at_str |
str |
both | recommended — already formatted short, no seconds: 2026-06-21 23:25 (UTC) |
Prefer expires_at_str
Use {{ expires_at_str }} instead of {{ expires_at }} — the
bundled templates do. It's localized (per AUTH_DEFAULT_LOCALE) and
drops the noisy seconds/microseconds. The raw expires_at is still
there if you need a custom format.
Example: lean emails/activation.html¶
<!doctype html>
<html lang="en">
<body style="font-family: sans-serif; max-width: 480px; margin: auto;">
<h1>Welcome{% if user.name %}, {{ user.name }}{% endif %}!</h1>
<p>To activate your account, click the button below:</p>
<p>
<a href="{{ activation_url }}"
style="display: inline-block; padding: 12px 24px;
background: #4f46e5; color: white;
text-decoration: none; border-radius: 6px;">
Activate account
</a>
</p>
<p style="color: #6b7280; font-size: 12px;">
Link valid until {{ expires_at_str }}.
If you didn't create this account, ignore this email.
</p>
</body>
</html>
Jinja2 only runs when there's a real email
In modes C (AUTH_RETURN_TOKEN_IN_RESPONSE=true) and D (AUTH_AUTO_ACTIVATE=true) the Jinja2 template is not rendered — the link goes out raw in the JSON, no HTML. Only modes A and B (real or intercepted SMTP) exercise the template.
Security¶
- Token stored as SHA-256 hash. Plaintext leaves via email only once; the database can never reproduce the original token. A leak of the
user_tokenstable does not enable retroactive activation. - One-shot.
used_atis stamped on consume; replay rejected withUnauthorizedException. - TTL-bounded.
expires_atcomputed fromAUTH_ACTIVATION_TTL_SECONDS/AUTH_PASSWORD_RESET_TTL_SECONDS. Expired tokens rejected. - Anti-enumeration.
POST /auth/password-reset/requestalways returns HTTP 202 + a generic body, regardless of whether the email exists.POST /auth/loginraises the sameUnauthorizedExceptionfor wrong-email, wrong-password and inactive account, and all three pay one bcrypt verification (an unknown email is checked against a throwaway hash), so the response time does not separate them either. Measured on this machine (bcrypt cost 12,UserAuthService.logincalled directly, median of N=21): wrong password 153.8 ms, unknown email 153.8 ms; before the fix, 0.5 ms for the unknown email. - Password floor enforced twice.
SignupSchemavalidates on input;UserAuthServicere-validates before hashing — defense in depth in case anyone bypasses the schema.
Getting the current_user from the request¶
make_auth_router issues the JWT pair (login/activate return access_token + refresh_token). But what next? When the frontend sends Authorization: Bearer <access_token> to your own routes, you need a dependency that decodes the token and resolves the user.
Since v0.49.0, UserAuthService builds that dependency for you — current_user_dependency(). It:
- Looks the token up in three places, in header → cookie → query string order (first hit wins):
Authorization: Bearer <jwt>viaHTTPBearer, then the cookie (cookie_name), and finally the query parameter (query_param). - Decodes and verifies the JWT with the same
JWTUtilsthe service signs with — no second secret to keep in sync. - Pulls the
sub(user id) from the payload, opens a session fromdb=, and returns the persistedUserModel. - Refuses a deactivated account with
403(v0.305.0+) — see below.
1. Declare the dependency once¶
The service already has user_model, JWTUtils and the session — so you don't write load_user by hand. Group both variants in src/api/dependencies/auth.py:
# src/api/dependencies/auth.py
from src.api.dependencies.resources import auth_service
get_current_user = auth_service.current_user_dependency()
get_current_user_or_none = auth_service.current_user_dependency(soft=True)
Requires db= on UserAuthService
current_user_dependency needs db= (the AsyncDatabaseManager from Minimum setup) — without it, it raises RuntimeError. Because it reuses the internal self.jwt, the token is verified with the same secret that signed it — the divergent-JWT_SECRET footgun is gone.
The user is loaded on the request-scoped session (db.session_dependency by default) — the very one your repositories use. So the instance comes back attached and you can mutate / refresh it without hitting InvalidRequestError: Instance is not persistent within this Session. If your repositories depend on a different session callable (a project-local get_session, say), pass that exact callable as session_dependency= — FastAPI caches a sub-dependency by callable, so a distinct wrapper opens a second session and detaches the user again.
The same guarantee holds for make_auth_router's own authenticated routes (/auth/password-change, /auth/mfa/*): they load the user on the request session as of 0.171.1. Before that the router opened a private session and handed back an already-detached instance, so the write was silently dropped and the following refresh blew up — /auth/password-change answered 500 while keeping the old password.
A deactivated account does not pass, even with a valid token (v0.305.0+)
login already refuses an inactive account, but a token issued
before the deactivation stays valid until it expires — the whole
access TTL. So the dependency checks is_active after loading the user
and answers 403 ACCOUNT_INACTIVE (the same AuthExceptions.account_inactive login uses); up to 0.304.0 it returned the row and every
route had to remember require_active. The authenticated routes of
make_auth_router (/auth/me, password and email change, MFA, passkeys)
run the same check.
soft=TrueyieldsNonefor an inactive account, as for an invalid token.inactive_exception=YourExceptionswaps the403for your class.require_active=Falserestores the old behavior — for the route that must serve an inactive account (reactivation, say).
Measured in tests/auth/test_current_user_active.py: token issued,
account deactivated, request with the token → 403; with
require_active=False → 200.
Not header-only: cookie and query string count too
The dependency tries header → cookie → query string and stops at the first hit, so the single line above serves both bearer clients and cookie clients.
- Cookie — automatic. With
AUTH_TOKEN_DELIVERYset to"cookie"or"both",cookie_nameis auto-derived fromAUTH_ACCESS_COOKIE_NAME— the same cookie the bundled/auth/loginset. Zero extra wiring: a route guarded by this dependency already accepts that cookie. A bearer-only delivery mode leaves itNone(header only). Passcookie_name="..."to force a name. - Query string — opt-in. Never auto-derived. It exists for clients that can send neither a header nor a cookie — the classic case being the browser
EventSource(SSE) cross-origin:
# src/api/dependencies/auth.py
from src.api.dependencies.resources import auth_service
get_current_user = auth_service.current_user_dependency(
cookie_name="access_token",
query_param="access_token",
)
Full SSE walkthrough in the SSE recipe ».
A token in the URL leaks
Query strings land in access logs, browser history and the Referer header. Enable query_param only over TLS, only with a short-lived access token (never a refresh token), and scrub the value from your log format. When the client shares the API's origin, prefer a session cookie (withCredentials).
No UserAuthService? Build the dependency by hand
If your service doesn't use the bundled flow, the make_jwt_user_dependency primitive accepts any JWTUtils + a one-argument async user_loader:
from uuid import UUID
from tempest_fastapi_sdk import JWTUtils, make_jwt_user_dependency
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel
from src.db.repositories import UserRepository
tokens: JWTUtils = JWTUtils(
secret=settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
)
async def load_user(subject: str) -> UserModel:
"""Resolve the JWT subject (a UUID string) to the persisted user."""
async with db.get_session_context() as session:
repo: UserRepository = UserRepository(session)
return await repo.get_by_id(UUID(subject))
get_current_user = make_jwt_user_dependency(tokens, load_user)
get_current_user_or_none = make_jwt_user_dependency(tokens, load_user, soft=True)
Heads up: here tokens must use the same JWT_SECRET / JWT_ALGORITHM as login, otherwise every valid token is rejected.
2. Inject it on the route with Depends¶
# src/api/routers/users.py
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import get_current_user
from src.db.models import UserModel
from src.schemas import UserResponseSchema
router: APIRouter = APIRouter(prefix="/users", tags=["users"])
@router.get("/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> UserResponseSchema:
"""Return the user who owns the request's bearer token."""
return UserResponseSchema.model_validate(current)
current is the UserModel the service resolved — typed, persisted, ready to use. Missing or invalid token → 401 UnauthorizedException before the route body runs.
For a plain /me, write nothing
The bundled router has mounted GET /auth/me since v0.198.0, with
AuthUserSchema (the columns BaseUserModel guarantees) as the
response. Write your own only when the route does something beyond
returning the account — aggregating counters, joining a profile from
another table. If it is just one extra column on the same table,
subclass the schema instead of rewriting the route:
Your session_factory has to guarantee nothing
The HTML pages render after the commit that consumes the token, and
SQLAlchemy's async_sessionmaker defaults to expire_on_commit=True —
which made the page read an expired column and answer 500
(MissingGreenlet) through v0.265.0. The router now reloads the row
only when it expired, checking inspect(user).expired, which never
touches the database. With AsyncDatabaseManager's factory
(expire_on_commit=False) there is no extra query at all.
3. Optional auth — soft=True¶
For routes that work both authenticated and anonymous (e.g. a public feed that personalizes when logged in), use the soft variant — it returns None instead of raising:
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import get_current_user_or_none
from src.db.models import UserModel
from src.schemas import PostResponseSchema
from src.services import FeedService
feed_service = FeedService()
router = APIRouter()
@router.get("/feed")
async def feed(
current: UserModel | None = Depends(get_current_user_or_none),
) -> list[PostResponseSchema]:
"""Public feed; personalizes the ranking when a user is logged in."""
if current is None:
return await feed_service.public()
return await feed_service.personalized(current.id)
Role and permission are the next step
When the route needs a role (admin) or permission (users:write) and not just "logged in", swap for make_role_dependency / make_permission_dependency. See the HTTP recipe » — same JWTUtils, same Depends pattern.
4. Imperative guards — checks inside the service / controller¶
The dependencies above gate the route (before the handler runs). But what about when you already hold the user deeper in the stack (service, controller) and just want to assert a condition before continuing? Since v0.50.0 the SDK ships three ready-made guards — no rewriting if user is None: raise ... in every service:
| Guard | Raises when | HTTP status |
|---|---|---|
require_authenticated(user) |
user is None |
401 UnauthorizedException |
require_active(user) |
None, or not user.is_active |
401 / 403 ForbiddenException |
require_admin(user) |
None, or not user.is_admin |
401 / 403 ForbiddenException |
The detail that matters: each one returns the user already narrowed — non-None, with the concrete type preserved — so the rest of the function stops seeing | None:
from tempest_fastapi_sdk import require_admin
from src.db.models import UserModel
class ReportService:
async def delete_all(self, current: UserModel | None) -> None:
"""Only an admin may purge reports."""
admin: UserModel = require_admin(current) # 401/403, or returns typed
await self.repository.purge(by=admin.id) # `admin` is no longer `| None`
It pairs directly with current_user_dependency(soft=True): the route passes UserModel | None, and the guard decides in the service.
Already have auth_service? Use the static mirrors
The same guards exist as static methods on UserAuthService — auth_service.require_admin(current) — for when you already inject the service and don't want an extra import. Same semantics, same exception.
Recap¶
- The bundled flow covers signup, activation, login, password reset and email
change — and the concrete
UserTokenModelis the only table your service has to ship. - Password recovery and email recovery are different flows: one is for someone who forgot the password, the other for someone who lost the inbox.
- The five operating modes exist because "who renders the page" changes per project: pure API, backend-rendered, SPA, or a mix.
AUTH_TOKEN_DELIVERYdecides whether the JWT pair leaves in the body, in a cookie or both — and a cross-site cookie needsSameSite=noneplusSecure.- Templates and language are overridable, but the defaults work without you creating a single file.
- The router prefix and the template URL have to agree: mounting under
/apiwith a template pointing at the root fails on the user's click, not at deploy time.
Next steps¶
- Idempotency » — protect
POST /auth/signupfrom retries that would duplicate the row. - MinIO/S3 Storage » — attach avatar / profile picture during signup.
- Logging » —
request_idpropagates automatically across every log line emitted during the flow. - Metrics » —
PrometheusMiddlewarecounts/auth/*separately with no extra config.