Skip to content

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

  1. Minimum setup — extras install + wiring four objects (AsyncDatabaseManager, EmailUtils, UserAuthService, make_auth_router).
  2. Concrete UserTokenModel — BaseUserTokenModel is abstract, your project owns the concrete table.
  3. 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.
  4. Re-send the activation — the activation email never arrived.
  5. Password recovery — the "forgot password" flow, step by step, plus changing the password while logged in.
  6. Email change and recovery — change email logged in, re-verify, and recover when the mailbox is lost.
  7. 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.
  8. Email anatomy: how link, template and URL fit together — disambiguates the three concepts that confuse readers the most.
  9. Email and page language (i18n) — default language, per-user preference and your own translation.
  10. 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).
  11. Token delivery — bearer, cookie or both.
  12. Mailhog vs smtp4dev — which to pick for local dev — comparison + copy-paste docker-compose snippets.
  13. Customizing email templates — override activation.html and password_reset.html + variables exposed to the Jinja2 context.
  14. Security — token storage, TTL, anti-enumeration.
  15. Getting the current_user from the request — the authenticated-user dependency in your routes.
  16. 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.
uv add "tempest-fastapi-sdk[auth,email]>=0.151.1"

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/request first.)
  • /auth/password-change — the user remembers their password and is logged in. They send the access_token in the Authorization: Bearer … header and re-confirm their current_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:

AUTH_SIGNUP_ENABLED=false

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=False turns 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/refresh and the other routes go through none of these limits, and the OpenAPI schema only declares the 429 on the login routes and /auth/signup while 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

uv add "tempest-fastapi-sdk[auth,email,sqlite]" uvicorn

[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 no EmailUtils, the activation link comes back in the body of signup (the activation_url field). You don't need AUTH_RETURN_TOKEN_IN_RESPONSE=true: with email=None the link shows up anyway, and it is the only place it shows up.
  • db.create_tables() — creates users and user_tokens in SQLite (./app.db, the DATABASE_URL default). 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 the current_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:

uv run python main.py

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:

curl 127.0.0.1:8000/api/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"

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:

# .env
AUTH_ACTIVATION_URL_TEMPLATE=meuapp://ativar?token={token}

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 signup body while email=None; with real email, only in the email.
  • POST /auth/activate/{token} activates and already returns the JWT pair; GET on the same path is the Mode E HTML page, which asks for a login afterwards.
  • AUTH_ACTIVATION_URL_TEMPLATE points at whoever will open the link: the frontend, the backend (Mode E) or the app (deep link).
  • The access_token goes in Authorization: Bearer … on any route guarded by current_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_active is still False;
  • email-verify/request exists 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"}'
{"message": "If the email matches an account, a reset link was sent.", "reset_url": null}

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 a detail naming the reason (unknown token: token not recognized).
  • A token shorter than 16 characters → 422 from FastAPI itself (string_too_short), before it reaches the service.
  • new_password violates AUTH_PASSWORD_MIN_LENGTH / complexity → 422.
  • request never 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_email already 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_code is required.
  • Keep AUTH_EMAIL_CHANGE_NOTIFY_OLD=True so 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_LENGTH characters passes, with no composition requirement.
  • complexity=true: on top of the 4 character classes, the effective length floor becomes max(AUTH_PASSWORD_MIN_LENGTH, 8). That is, an AUTH_PASSWORD_MIN_LENGTH below 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).

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.
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_tokens table. 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 calls POST /auth/activate/{token} on the backend. In Mode E it points at the backend, which activates the account on GET. Full table in Where the template points.
  • Jinja2 template (AUTH_ACTIVATION_TEMPLATE) — filename of the HTML template inside EmailUtils.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_at is still available as the raw datetime if 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:

# .env
AUTH_DEFAULT_LOCALE=pt-BR   # default — you can even omit it

Want everything in English? Switch to:

AUTH_DEFAULT_LOCALE=en-US

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:

This link expires at 2026-06-21 23:25:49.742054+00:00

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. Set en-US if you want English.
  • AUTH_STAMP_LOCALE_IN_LINK=false turns 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=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
AUTH_BACKEND_LINKS=false
Real 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=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP 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=false
AUTH_RETURN_TOKEN_IN_RESPONSE=true
email=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=true
URL 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.

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)

AUTH_AUTO_ACTIVATE=true

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:

app.include_router(make_auth_router(...), prefix="/api")

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):

  1. template_dir/<locale>/activation_success.html — override just that language (e.g. src/templates/auth/pt-BR/...).
  2. 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_router raises RuntimeError at 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 CSRFMiddleware if 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.

# .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

AUTH_TOKEN_DELIVERY=both

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_tokens table does not enable retroactive activation.
  • One-shot. used_at is stamped on consume; replay rejected with UnauthorizedException.
  • TTL-bounded. expires_at computed from AUTH_ACTIVATION_TTL_SECONDS / AUTH_PASSWORD_RESET_TTL_SECONDS. Expired tokens rejected.
  • Anti-enumeration. POST /auth/password-reset/request always returns HTTP 202 + a generic body, regardless of whether the email exists. POST /auth/login raises the same UnauthorizedException for 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.login called 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. SignupSchema validates on input; UserAuthService re-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:

  1. Looks the token up in three places, in header → cookie → query string order (first hit wins): Authorization: Bearer <jwt> via HTTPBearer, then the cookie (cookie_name), and finally the query parameter (query_param).
  2. Decodes and verifies the JWT with the same JWTUtils the service signs with — no second secret to keep in sync.
  3. Pulls the sub (user id) from the payload, opens a session from db=, and returns the persisted UserModel.
  4. 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=True yields None for an inactive account, as for an invalid token.
  • inactive_exception=YourException swaps the 403 for your class.
  • require_active=False restores 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_DELIVERY set to "cookie" or "both", cookie_name is auto-derived from AUTH_ACCESS_COOKIE_NAME — the same cookie the bundled /auth/login set. Zero extra wiring: a route guarded by this dependency already accepts that cookie. A bearer-only delivery mode leaves it None (header only). Pass cookie_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:

from tempest_fastapi_sdk import AuthUserSchema, make_auth_router


class UserResponseSchema(AuthUserSchema):
    name: str | None = None


app.include_router(
    make_auth_router(
        auth_service,
        session_factory=get_db,
        me_response_model=UserResponseSchema,
    )
)

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:

from tempest_fastapi_sdk import (
    require_active,
    require_admin,
    require_authenticated,
)
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 UserTokenModel is 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_DELIVERY decides whether the JWT pair leaves in the body, in a cookie or both — and a cross-site cookie needs SameSite=none plus Secure.
  • 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 /api with a template pointing at the root fails on the user's click, not at deploy time.

Next steps

  • Idempotency » — protect POST /auth/signup from retries that would duplicate the row.
  • MinIO/S3 Storage » — attach avatar / profile picture during signup.
  • Logging » — request_id propagates automatically across every log line emitted during the flow.
  • Metrics » — PrometheusMiddleware counts /auth/* separately with no extra config.