A security release, from a full audit of the template's product code. Upgrade if you generated with --auth-mode delegated, --rate-limiting, or email sign-in links. There are breaking changes and one migration — see Upgrading.
One pattern runs through most of this: the mechanism was built and correct, and nothing attached it. Rate limiting shipped twice and guarded no route. The reranker was warmed at startup and thrown away on every request. Delegated auth was half-wired. All of it lived in configurations no CI job rendered — 53 of the 88 CLI flags were never generated — so nothing was ever in a position to notice.
Delegated auth was an unauthenticated path to database admin
--auth-mode delegated means identity belongs to your IdP, and get_current_user accepts nothing but IdP signatures. auth.py was never told: it still generated /auth/login, /auth/register, /auth/refresh and /auth/logout, backed by the local password column.
The chain: on a fresh delegated deployment the users table is empty until an IdP user first authenticates. UserService.register promotes the first row to role=admin + is_app_admin=True. SQLAdmin's AdminAuth authenticates on a local password and never consults the IdP. And the panel is live for development/local/staging, which is what .env.example ships. So in the window between deploying and the first real sign-in, an unauthenticated caller could POST /auth/register and then log into /admin with full CRUD over every table.
Delegated mode now generates only /auth/me. AdminAuth additionally requires is_app_admin and, under delegated auth, an IdP-linked row — so the guarantee is enforced locally instead of depending on a route elsewhere staying absent.
Two more in the same mode: the chat WebSocket validated tokens against the local SECRET_KEY, so IdP tokens were rejected (chat never connected at all) while a token from the still-mounted /auth/login was accepted — the account above could drive the agent and read the knowledge base. And delegated auth was outright non-functional without --oauth, because get_or_create_from_idp sat inside the OAuth gate and every authenticated request raised AttributeError.
Rate limiting was advertised and enforced on nothing
Two complete implementations shipped. main.py set app.state.limiter and the 429 handler but never added SlowAPIMiddleware; no route carried @limiter.limit; and make_rate_limit_dep had zero call sites outside its own docstring — it could not have served /auth/* anyway, since it required CurrentUser on endpoints that run before authentication. The Redis service even had the right rule written down (5 per 15 minutes per IP) and unused. /auth/login accepted unlimited attempts, each burning a bcrypt verification.
The sliding-window service is now attached to /auth/login, /auth/register, /password-reset/request and /magic-link/request, with a new anonymous per-IP variant for pre-auth routes. The slowapi copy and its dependency are gone.
It also could not be imported at all in two common setups: service.py imported ActiveOrg (only exists with --teams) and storage.py imported get_redis from app.core.cache — a function that does not exist in that module, in a file --no-caching deletes.
Password-reset and magic links were replayable
create_password_reset_token's docstring said "Single-use JWT" and nothing consumed it — no jti, no denylist, no password_changed_at anywhere in the app. A link that leaked once (forwarded mail, a shared support inbox, a logging proxy) stayed usable for the rest of the hour, including after the legitimate user had used it, which locks them out of the account they just recovered.
Reset tokens now carry a digest of the password hash they were issued against, so the reset itself invalidates them — stateless, no new table. Magic links carry an epoch that redemption increments, invalidating the redeemed link and every other one outstanding for that user.
The cross-encoder reranker reloaded its model on every search
The lifespan built a RerankService, called warmup() — which loads ms-marco-MiniLM-L6-v2 into memory — and stored it in state["rerank_service"]. Nothing ever read it back, and get_retrieval_service constructed a fresh one per request. Since CrossEncoderReranker holds its model on the instance and loads it lazily, every RAG search paid the load again: seconds of CPU and ~90 MB, with N concurrent searches holding N copies. get_embedding_service and get_vectorstore both already checked request.state; the reranker was the one that was missed.
Also fixed
- Reading a webhook returned 500 until it had been modified once —
WebhookRead.updated_atwas non-optional against a nullable column. - Connector secrets crashed on any RAG project without a Telegram or Slack bot —
sync_source.pyencrypted withCHANNEL_ENCRYPTION_KEY, which is only declared when a messaging channel is enabled. - Four of the five
AgentSessionvariants treated unknown control frames as prompts. The shareduse-chat.tssends{"type":"resume"}and{"type":"ask_user_response"}regardless of which framework you generated, and only the PydanticAI copy ignored what it did not implement — the others answered "Empty message". - A Redis failure inside the limiter raised
TypeErrorinstead of logging it. - The generator stopped installing your project's dependencies.
celery,taskiq,arq,stripe,pytest,pytest-asyncio,httpxandpydantic-settingswere runtime dependencies offastapi-fullstackand imported by none of it — roughly 20 MB on everyuvx fastapi-fullstack, stripe alone 13 MB. - Removed three modules that were written and never wired:
core/csrf.py,api/versioning.py,core/rate_limit.py. Corrected four documentation claims describing code that does not exist.
CI
Four new jobs render what these bugs were hiding in: delegated auth in both JWKS and shared-secret modes (admin panel on — that is the combination that escalated), rate limiting with and without teams, and RAG with a cross-encoder reranker.
The old rate-limit test was assert limiter is not None, which an unattached limiter passes. It is replaced by tests that assert a 429 actually comes back, plus replay regressions for both link types — including two outstanding magic links, where redeeming the newer one must invalidate the older.
Upgrading
uv run alembic upgrade head # 0027 adds users.magic_link_epochWithout that migration, sign-in-by-email raises on every attempt.
Breaking:
- Delegated deployments lose
/auth/login,/auth/register,/auth/refresh,/auth/logoutand the password-reset and magic-link routes. They were handing out tokens the API rejected, so nothing that worked stops working — but a client calling them will now get a 404 instead of a token. create_password_reset_tokentakescurrent_password_hash;create_magic_link_tokentakesmagic_link_epoch. Both bind the token to state that changes on redemption. Custom callers must pass the new argument.- The slowapi limiter is gone. If you wired
app.state.limiteror@limiter.limityourself, port it tomake_rate_limit_dep/make_anonymous_rate_limit_dep. - Rate limits now actually fire. Load tests and smoke scripts that hammer login will start seeing 429s. Tune via a plan's
features.rate_limits, orDEFAULT_RATE_LIMITSinbackend/app/services/rate_limit/rules.py. - Generated with
--rate-limitingand no Redis? Counters are per worker process, so the effective limit is multiplied by your worker count. Enable Redis for a shared window.
Full details in UPGRADES.yaml and the changelog.