Prima Get an API key

Module kit / Identity and auth

Password reset

identity-auth/password-reset

Kind backend
Domain identity-auth
Maturity prod
Reusable yes
Stack fastapi, sqlalchemy-sync, postgres, smtp
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout
Frontends identity-auth/ui/auth-screens, identity-auth/ui/profile-and-passkeys
Reference implementation
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/services/auth_service.py
  • alchemy_labs/backend/models/user_model.py
  • alchemy_labs/backend/services/email_service.py
  • alchemy_labs/backend/api/routers/user_router.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • alchemycloud main b3d34da 2026-09-12
  • philosophers_stone main 6322fa3 2026-09-12
Version 1

Purpose

A person who has a password and has lost it asks for a link, sets a new password from that link within six hours, and every session they held before is dead the moment the new password lands. The invariant that makes it trustworthy is that a reset can only give a password to an account that already has one and holds the labs grant, so an unauthenticated request can never turn a deliberately passwordless account (an api_only signup, a reviewer, a shared desk account) into a password login, and the request route answers 202 whether or not any of that was true.

This recipe fixes the two public routes, the password_resets row, the anti-enumeration contract, the session revocation on reset, and the authenticated change-password route that shares the revocation; it also records the second, process-local change-password flow the profile page uses today and why D8 retires it.

When to use

Trigger phrases: "forgot my password", "reset link", "the reset mail never came", "log out everywhere after a reset", "change password from the profile", "the code says it was sent but nothing arrived", "an API account got a password". A password is a Labs-UI credential: only labs accounts get one, and the reset link always points at the Labs SPA.

Do not use it for the passwordless door (identity-auth/magic-link-login, B5, the primary sign-in on every surface), for the first password after registration (identity-auth/registration-with-email-verification, B1: POST /api/auth/set-password with a verification token), for the session and denylist mechanics themselves (identity-auth/session-login-refresh-logout, B2, and S5), or for the limiters (identity-auth/rate-limiting, B18).

Stack assumptions

Pinned as measured at fd8558f (backend/requirements.txt): fastapi==0.118.2, SQLAlchemy==2.0.43, bcrypt==5.0.0 (hash_password, bcrypt.gensalt() with no rounds argument, so the library default of 12, 72-byte truncation, S5), pydantic==2.12.0 (the @validator on the new password), resend==2.7.0 or SMTP through _send_email as EMAIL_TRANSPORT selects, redis==6.4.0 for the optional denylist. Foundations assumed: S4 (mounted with auth_router at /api/auth on ALL_SURFACES, app_fastapi.py 820; user_router at /api/user on LABS_SURFACES, line 875), S5, S7, S9.

Environment names read on this path (core/config.py unless the row says otherwise):

NameDefaultBehaviour
PASSWORD_RESET_EXPIRE_HOURS6expires_at = utcnow + hours on the row (auth_service.py 986 to 991)
FRONTEND_URLhttp://localhost:3000the reset link base, always; the surface registry is not consulted (email_service.py 770 to 771)
EMAIL_TRANSPORT, RESEND_API_KEY, EMAIL_SMTP_*as in B5transport_is_configured() gates the send; nothing configured is 202 and no mail
JWT_DENYLIST_ENABLEDoffnot declared in core/config.py (grep -n JWT_DENYLIST_ENABLED backend/core/config.py prints nothing at fd8558f); _denylist_enabled() (auth_service.py 57 to 73) reads it from settings when the field exists and otherwise from os.getenv, false by default. On: every active session's access and refresh jti is denylisted at reset (1026 to 1034); off: the session rows go inactive and access tokens live to expiry (S5)
TRUSTED_PROXY_COUNT1the per-IP limiter's client address (core/config.py 47)

Declared and not read (measured 2026-09-12 at fd8558f): BCRYPT_ROUNDS (core/config.py 131, default 12) has no reader outside its declaration (grep -rn BCRYPT_ROUNDS backend --include='*.py' hits that line only); hash_password (auth_service.py 346 to 352) calls bcrypt.gensalt() with no rounds argument, so the cost is the bcrypt library default of 12 and setting the variable to anything changes nothing. A new service either reads it or drops it.

The second flow reads RESEND_API_KEY directly (user_router.py 229) and bypasses EMAIL_TRANSPORT; see Gotchas.

Data model

One table, owned here; columns as declared in models/user_model.py lines 199 to 211. The DDL is split over two revisions: migrations/versions/baseline_prod_schema_001.py creates the table (1451 to 1459), the primary key (2618 to 2619) and the unique index (3838) with no foreign key; migrations/versions/deferred_fk_phantom_tables_001.py (down revision meta_assets_001) adds fk_password_resets_user_id_users with the cascade (535 to 538) and drops it in its downgrade (1059):

password_resets (
  id         uuid PRIMARY KEY,                                        -- pk_password_resets
  user_id    uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,    -- FK from deferred_fk_phantom_tables_001, not the baseline
  token      varchar(255) NOT NULL,                                   -- secrets.token_urlsafe(32), stored verbatim
  used       boolean,                                                 -- nullable, no server default; the ORM writes false on insert (model 205); a NULL never matches `used = false`, so a row written outside the ORM with NULL cannot be redeemed
  expires_at timestamp NOT NULL,                                      -- naive UTC, created + PASSWORD_RESET_EXPIRE_HOURS
  created_at timestamp,
  used_at    timestamp
);
CREATE UNIQUE INDEX ix_password_resets_token ON password_resets(token);

Columns the reset writes elsewhere: users.password_hash (the new bcrypt), sessions.is_active = false for every row of the user (auth_service.py 1037 to 1041). No surface column: a reset is labs by construction, enforced at request time by the grant check rather than by a column.

What is not stored: the requesting IP, the previous hash, a hash of the token, the six-digit code of the second flow (it lives in a module-level dict, user_router.py 30, keyed by user id with a time.time() expiry). Nothing purges expired rows (grep over backend/ outside tests, migrations and the two service methods finds no reader of password_resets).

API surface

RouteAuth depTenancyCodesError codes and bodies
POST /api/auth/forgot-passwordnone; auth_rate_limit (30 per minute per client IP, keyed by path)none202 always, body {"message": "If the email exists, a password reset link has been sent"} (auth_router.py 899)422 for a malformed email (PasswordResetRequest {email: EmailStr}, schemas/auth_schemas.py 99 to 100); 429 from the per-IP limiter, and at fd8558f that 429 carries no Retry-After: the dependency raises the header and http_exception_handler (app_fastapi.py 724 to 788) never reads exc.headers (Gotchas; measured in identity-auth/rate-limiting), while a service built to this recipe forwards it. No per-address throttle (Gotchas)
POST /api/auth/reset-passwordnone; auth_rate_limitnone204400 "Invalid or expired reset token" for unknown, used and expired tokens alike (913 to 917); 422 when new_password is under 8 characters or lacks an uppercase letter, a lowercase letter or a digit (PasswordResetConfirm, 102 to 115: the Pydantic error text, e.g. Password must contain at least one uppercase letter); 429
POST /api/auth/change-passwordActiveUsernone204 (at fd8558f the 204 revokes every session and writes no hash; Gotchas)400 "Current password is incorrect" (856 to 861); 401 uniform; 422 from PasswordChange {current_password, new_password} (schemas/auth_schemas.py 80 to 97): new_password under 8 characters, or New password must be different from current password, or the three rule messages shared with PasswordResetConfirm (uppercase, lowercase, digit)
POST /api/user/password/request-codeActiveUser; mounted on LABS_SURFACESnone200 {"message": "Verification code sent to your email"} whether or not a mail left (user_router.py 211 to 248)401 uniform; 403 for a non-labs session at the gate
PUT /api/user/passwordActiveUser; LABS_SURFACESnone200 {"message": "Password updated successfully"} (at fd8558f the 200 writes no hash; Gotchas)400 "No verification code requested. Please request a code first.", "Verification code has expired. Please request a new one.", "Invalid verification code" (259 to 278); 422 for a code that is not six characters or a password under 8 (PasswordUpdateRequest, 38 to 41)

The 204 routes set no cookies and return no tokens: after a reset the person signs in again through /login or a magic link. The reset mail's link is <FRONTEND_URL>/reset-password?token=<t> (email_service.py 771), which the SPA's /reset-password route reads.

Application logic

forgot_password (auth_router.py 879 to 899), in order:

  1. auth_rate_limit (per IP).
  2. auth_service.create_password_reset(email) (auth_service.py 951 to 994): in its own session, load the user by lowercased email; None when missing; None with a WARNING (Refusing password reset for passwordless account <id>) when password_hash IS NULL; None with a WARNING (Refusing password reset for user <id>: no labs grant) when labs is not in get_user_surfaces(db, user.id); otherwise insert a row with token = secrets.token_urlsafe(32) and expires_at = utcnow + PASSWORD_RESET_EXPIRE_HOURS, commit, return the token. is_active is not consulted at request time.
  3. If a token exists and transport_is_configured(): background_tasks.add_task(email_service.send_password_reset_email, email, token) (891 to 896), two positional arguments, so the task runs with db=None and the password_reset row in email_templates is never consulted; the hard-coded German fallback is what leaves (send_password_reset_email, 765 to 806: get_template_by_name(db, "password_reset") only when db is given).
  4. Return 202 with the fixed message.

reset_password (auth_router.py 901 to 919):

  1. auth_rate_limit; the body validator has already refused a weak password with 422.
  2. auth_service.reset_password(token, new_password) (auth_service.py 996 to 1044): in its own session, select the row where token matches, used == False and expires_at > utcnow; False otherwise. Load the user (scalar_one), set password_hash = hash_password(new_password), mark the row used with used_at. With the denylist on, select every active session of the user and denylist both its access and its refresh token (_denylist_token, 1026 to 1034). Then UPDATE sessions SET is_active = false WHERE user_id = :u (1037 to 1041), commit, True.
  3. False is 400; True is 204.

change_password (845 to 877), as a service built to this recipe runs it: verify current_password against the stored hash with verify_password (a passwordless account fails here with 400 because hashed_password is None); select the User in the route's own session; write the new hash; commit; set every session of the user inactive; commit; 204. As the reference runs it at fd8558f: the verify is the same (856 to 861); the assignment at 865 lands on current_user, which ActiveUser hands in as a detached instance (Gotchas), and the db.commit() at 866 has nothing to flush; the session UPDATE at 869 to 875 is raw SQL on db and does run. Net effect measured 2026-09-12: 204, users.password_hash unchanged, the person logged out everywhere and still holding the old password. It does not touch the denylist, so with the flag on a reset revokes harder than a change does.

The second flow (user_router.py 211 to 290): request_password_change_code draws secrets.randbelow(1000000) as six digits, stores {code, expires_at: time.time() + 600} in the module dict under the user id, and if settings.RESEND_API_KEY is set calls email_service._send_via_resend with an English hard-coded template, else logs the code at INFO and still answers "sent". update_password reads the dict, refuses a missing, expired or mismatching code, pops it, assigns the new hash on the same detached current_user (286 to 287) and commits, which persists nothing at fd8558f (Gotchas). It revokes no session and touches no denylist, so a 200 from this route changes nothing at all.

Invariants

  • A reset is issued only to an account with password_hash IS NOT NULL and a labs grant (create_password_reset 971 to 984; test_password_reset_cannot_give_a_passwordless_account_a_password, tests/test_surface_scoped_access.py 530).
  • POST /forgot-password answers 202 with one fixed body for an unknown address, a passwordless account, an account without the grant and a successful mint; the only non-202 answers are 422 and 429.
  • A token is single-use and expires PASSWORD_RESET_EXPIRE_HOURS after mint; used, used_at and the new hash are written in one transaction (1018 to 1043).
  • After a successful reset or change the new password logs in at POST /api/auth/login and the old one answers 401. The reference at fd8558f holds this for reset_password (the hash is written on the user the service's own session loaded, 1014 to 1018) and fails it on both authenticated routes (Gotchas): after a 204 from /change-password or a 200 from PUT /api/user/password the old password still logs in and the new one is 401.
  • At redemption a service built to this recipe re-checks users.is_active and the labs grant and answers 400 when either fails, as verify_magic_link (B5) does; the reference at fd8558f asks both only at request time (Gotchas).
  • Every session of the user is inactive after a successful reset (1037 to 1041) and after a change (869 to 875); with the denylist on, every live token of the user is refused on the next request after a reset (reset_password 1026 to 1034). No test at fd8558f calls reset_password with the denylist on: test_token_rejected_after_password_reset (tests/v2/test_f2_token_revocation.py 147 to 156) calls _denylist_token directly and proves only that a denylisted jti is refused (Tests).
  • The new password satisfies the validator or the request never reaches the service (422): PasswordResetConfirm (min_length=8, one uppercase, one lowercase, one digit; schemas/auth_schemas.py 102 to 115) on the reset, PasswordChange (the same three rules plus New password must be different from current password; 80 to 97) on the change.
  • A reset never mints a session: the response is 204 and the next step is a fresh login through S5's create_session, which re-checks the labs grant.
  • The reset link's base is FRONTEND_URL, the one place the Labs SPA lives (email_service.py 770 to 771); a Prima surface has no password.

Frontend

The frontend modules that render this recipe, by id, and what each shows of it:

identity-auth/ui/auth-screens              ForgotPassword posts /forgot-password through api.requestPasswordReset
                                           (services/api.ts 155 to 160, pages/ForgotPassword.tsx 40) and renders
                                           the 202 as "check your mail" whatever happened; ResetPassword reads
                                           ?token=, checks the password client-side first (five rules: length 8,
                                           uppercase, lowercase, digit AND a special character, one stricter than
                                           the server's four; pages/ResetPassword.tsx 55 to 87), posts
                                           /reset-password (api.ts 162 to 168), and on failure shows
                                           err.response.data.error or one German fallback sentence ("Der Link ist
                                           möglicherweise abgelaufen", 113 to 116). The 400 carries the server's
                                           string in `error` (app_fastapi.py 724 to 788, S7's envelope), so the
                                           SPA shows the English "Invalid or expired reset token"; a 422 is a
                                           RequestValidationError with no handler and no `error` key, so it would
                                           show the fallback, but the client-side check keeps a weak password from
                                           leaving the browser, so that branch is unreachable from the SPA. After
                                           a 204 it navigates to /login three seconds later (110); the /login
                                           links are at 163 and 303, and 142 is the /forgot-password link of the
                                           missing-token state (frontend/src/App.tsx 250 to 251)
identity-auth/ui/profile-and-passkeys      the change-password card on /settings/profile uses the SECOND flow:
                                           api.requestPasswordChangeCode() -> POST /api/user/password/request-code,
                                           then api.updatePassword(code, new) -> PUT /api/user/password
                                           (services/api.ts 239 to 249, pages/settings/ProfileWithUsage.tsx 246);
                                           POST /api/auth/change-password has zero callers in frontend/src at fd8558f

Minimum admin and debug surface: the two WARNING lines from create_password_reset (passwordless account, no labs grant); there is no admin listing of password_resets and no route that revokes a pending reset (the row expires). For the second flow the only signal is the INFO line Password change code sent to <email> (user_router.py 247), which is emitted whether or not a mail left.

Security & tenancy

Auth dependency: none on the two public routes; ActiveUser on the three authenticated ones. Workspace derivation: none; a password belongs to a user, not a tenant. Fail direction: the passwordless and grant checks are closed (a None is a 202 and no mail); the mail transport is open (202, log line); the per-IP limiter is open when Redis is down (in-memory fallback per process); the denylist is open when Redis is down and off by default (S5), so after a reset an attacker holding a stolen access token keeps it for its remaining life (60 minutes; 15 with the flag on) while refresh is dead immediately.

Credentials at rest: password_resets.token verbatim, 43 url-safe characters, valid six hours, so the table is a list of live reset links; a new service stores sha256(token), the reference does not. users.password_hash is bcrypt at the library's default cost of 12 (BCRYPT_ROUNDS is declared and never read; Stack assumptions). The six-digit code of the second flow is process memory, unhashed, one code per user, replaced on every request.

Rate limits: auth_rate_limit on both public routes, 30 per minute per client IP keyed by path. There is no per-address throttle on /forgot-password although api/dependencies/rate_limit.py (26 to 35) names password resets as a purpose of enforce_email_rate_limit; the only caller of that function at fd8558f is the magic-link route (grep -rn enforce_email_rate_limit backend --include='*.py' hits its definition, its import and auth_router.py 1004). A rotating-IP sender can therefore mail one mailbox 30 reset links a minute per IP. The two authenticated routes of the second flow have no limiter; a request-code loop mails the account's own address.

Attacker-controlled inputs: email (validated, lowercased, lookup key and recipient); token (equality on a unique index); new_password (validated, then hashed, never logged); verification_code (compared with != against the dict, not constant-time; six digits gives a million-way space behind a session the attacker must already hold, and the code is popped after ten minutes or one match). Nothing from the request enters the mail body except the token in the URL.

Tenancy: a reset row belongs to one user; the labs grant is checked at request time and not at redemption, so a grant revoked between the mail and the click still lets the password change (the next /login refuses with 403, S5). The second flow is mounted on LABS_SURFACES, so a Prima session is 403 at the gate before the handler runs.

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 test Postgres with python -m pytest <file> -q --no-cov -o addopts="" -W ignore):

  • test_surface_scoped_access.py: 44 passed in 7.28s; test_password_reset_cannot_give_a_passwordless_account_a_password (530) is the load-bearing case: create_password_reset on an api_only address returns None and users.password_hash stays NULL.
  • tests/v2/test_f2_token_revocation.py: red or green depending on what the previous pytest process left in the test database, not a property of fd8558f. The file declares no database fixture (its only fixtures are enable_denylist and fake_redis, 75 to 85), but create_access_token resolves tid/tids_n through _resolve_tenant_claims, which opens get_db_context() when no session is passed (auth_service.py 278 to 298), so eight of its eleven tests need a workspaces table that the file never creates. The base test_engine fixture (tests/conftest.py 56 to 108, session scope) drops every table when its session ends, and tests/test_surface_scoped_access.py creates them and never drops (48 to 62). Measured 2026-09-12: run right after tests/test_api_endpoints.py -k test_update_user_settings (which requests db_session) it prints 8 failed, 3 passed, every failure psycopg2.errors.UndefinedTable: relation "workspaces" does not exist; run right after tests/test_surface_scoped_access.py it prints 11 passed. Green or red, test_token_rejected_after_password_reset (147 to 156) never calls reset_password: it mints a token, calls AuthService._denylist_token(token, token_type="access") itself and asserts the decode fails. The reset's own denylist loop (reset_password 1026 to 1034) has no test at fd8558f.
  • test_critical_endpoints.py::TestPasswordResetEndpoints (394 to 445): the same order dependence, since the file builds a TestClient on a module-scoped app (34 to 44) and creates no tables. Measured 2026-09-12: 14 failed, 33 passed after the drop_all above (test_forgot_password_endpoint_exists and test_reset_password_rejects_invalid_token among the failures, all UndefinedTable), 10 failed, 37 passed with the tables present. test_forgot_password_requires_email (410) and test_reset_password_requires_token_and_password (433) pass in both states: the 422s are real. Note for -k: requires_email also matches TestAuthEndpoints::test_login_requires_email (154) and TestMagicLinkEndpoints::test_request_magic_link_requires_email (365), so the selector in Acceptance check 1 names the class.
  • tests/unit/test_email_templates.py: 20 passed, 3 failed; test_can_send_password_reset_email (310 to 324) passes with a mocked transport and asserts only that the literal token appears in the HTML body (assert "reset-token" in call_args[0][2]); FRONTEND_URL is mocked to a placeholder and never asserted against, so the URL shape <FRONTEND_URL>/reset-password?token=<t> (email_service.py 770 to 771) has no test at fd8558f. The three failures are unrelated sample-variable and Resend-key cases.
  • No test exercises POST /api/auth/change-password or the two user_router routes as built: grep -rln "change-password\|password/request-code\|/api/user/password" tests hits only tests/test_api_endpoints.py, whose test_update_user_settings (189 to 195) still sends current_password to PUT /api/user/password and expects 200; measured 2026-09-12 it prints 1 failed with assert 422 == 200, because the route has taken verification_code since 797a04f (2026-03-19). No test at fd8558f logs in with the new password after any of the three authenticated or reset routes, which is how both authenticated routes came to answer success without writing the hash (Gotchas): the only assertions on those routes are status codes.

Hermetic versus live: all hermetic. Playwright: frontend/e2e/auth.spec.ts 114 to 115 (Password Reset: should request password reset) walks /forgot-password in the browser and expects the confirmation copy; frontend/e2e/critical-flows.spec.ts 64 (forgot password request works) posts the route and expects 202.

Known-answer vectors a rebuilt service must reproduce: the 202 body string; the 400 string Invalid or expired reset token; the URL shape (no green test guards it at fd8558f, so a rebuilt service adds one); a reset row with used = true after one use and a 400 on the second; SELECT count(*) FROM sessions WHERE user_id = :u AND is_active equal to 0 after a reset; the four validator messages of PasswordResetConfirm and the fifth, New password must be different from current password, of PasswordChange; after a 204 from /reset-password and after a 204 from /change-password, POST /api/auth/login with the new password is 200 and with the old password is 401 (the reference at fd8558f reproduces this for the reset only; Gotchas).

Deploy & ops

Config keys per app heredoc (S13, alchemycloud b3d34da): FRONTEND_URL, EMAIL_TRANSPORT and the SMTP triple, RESEND_API_KEY (the production heredoc keeps it beside smtp, lines 262 to 273, for callers that bypass the selector; the second flow is one of them), REDIS_URL. Not set on either heredoc, so the core/config.py default applies: PASSWORD_RESET_EXPIRE_HOURS (6), JWT_DENYLIST_ENABLED (off), TRUSTED_PROXY_COUNT (1; the name appears nowhere in alchemycloud at b3d34da).

Migrations order: baseline_prod_schema_001 creates the table, its primary key and its unique index; deferred_fk_phantom_tables_001 adds the cascade foreign key to users (535 to 538); nothing else touches it (grep -rn password_resets backend/migrations hits those two files only). The labs grant check needs surface_grants_001 (S5's table) to exist, which every fd8558f deployment has.

Scheduler jobs: none. A new service purges password_resets with the same S12 job as magic_links. The image runs uvicorn with --workers ${WEB_CONCURRENCY:-2} (Dockerfile 117), which is what makes the process-local dict of the second flow a defect rather than a style choice (Gotchas).

Argus checks: POST /api/auth/forgot-password with a syntactically valid unknown address answers 202; the transport check is S13's. Rollback: the routes are stateless apart from the row; a previous image keeps every unexpired reset valid. A password written by the new image is a bcrypt hash the old image verifies.

Gotchas

  • 2026-09-12 (measured at fd8558f; D8): neither authenticated password route writes the new hash. ActiveUser resolves through get_current_user, which loads the row with auth_service.get_user_by_id (api/dependencies.py 231; auth_service.py 1299 to 1305) inside get_db_context(), whose finally calls session.expunge_all() before closing (core/database.py 460), so every handler receives current_user as a detached instance (sqlalchemy.inspect(auth_service.get_user_by_id(id)).detached prints True). change_password (auth_router.py 865 to 866) and update_password (user_router.py 286 to 287) assign password_hash on that detached object and commit the route's own DatabaseSession, which holds no change. Measured with a hermetic TestClient against the :5434 test Postgres on a verified labs user: POST /api/auth/change-password with the correct current password answers 204, SELECT password_hash FROM users after expire_all() is unchanged, POST /api/auth/login with the old password is 200 and with the new one 401; POST /api/user/password/request-code answers 200, PUT /api/user/password with the code read out of _password_change_codes answers 200 Password updated successfully, the hash is unchanged and the new password is 401 at login. The session UPDATE in change_password (869 to 875) is raw SQL on db and does run, so that route logs the person out everywhere and leaves them the old password. Corroboration: PATCH /api/auth/me (822 to 843), which mutates current_user the same way and then calls db.refresh(current_user), answers 500 on the detached instance; user_router's working profile update re-selects the user in db first (77 to 78); reset_password writes the hash on a user its own session loaded (auth_service.py 1014 to 1018) and is the one route of the four that changes the password. Fix (D8, recorded, not applied in this wave): re-select the User in the route's db before assigning, the pattern at user_router.py 77 to 78 (or db.merge(current_user)), and add a test per route that logs in with the new password after the success status. This repair lands before the profile page is pointed at /api/auth/change-password (next Gotcha), or the retirement of the second flow ships a card that answers 204 and never changes the password.
  • 2026-09-12 (measured at fd8558f; D8, inventory row B6): the second password-change flow. POST /api/user/password/request-code and PUT /api/user/password (user_router.py 211 to 290, added 797a04f on 2026-03-19) keep the six-digit code in a module-level dict (line 30), so with the image's two uvicorn workers (Dockerfile 117) the PUT lands on the worker that holds the code only half the time and the other half answers 400 No verification code requested; the send calls email_service._send_via_resend directly, gated on settings.RESEND_API_KEY (229 to 244), bypassing EMAIL_TRANSPORT (ADR-0023), and on a host without that key logs the code and still answers 200 "sent"; a successful update revokes no session (280 to 290) and, per the previous Gotcha, writes no hash either. This is the flow the profile page calls (F5, services/api.ts 239 to 249), while POST /api/auth/change-password (845 to 877), which verifies the current password and revokes every session, has zero frontend callers and zero tests. Why the flow exists: 797a04f's message records that it replaced a current-password form because "users who signed up via magic link or admin-created accounts have no password_hash, so verifying current password was impossible", and made the profile card work "for all users regardless of whether they have a password set". Such accounts still exist at fd8558f: the desk's shared account (services/service_desk/access.py 640, password_hash=None, granted labs at 657), the desk sign-in's account (services/service_desk/signin.py 352), reviewer accounts (services/content_review_service.py 107), and magic-link registrations. After the fix below those accounts have no self-service route to a first password: change_password answers 400 on a NULL hash, forgot-password refuses them by design (Purpose), and the only writers of a hash on an existing row are B1's set_password_with_verification (auth_service.py 1219 to 1248, a verification token) and reset_password; an admin sets a password at creation only (admin_router.py 1463 onward, 1514 to 1515). This recipe takes that as the intended state: a password is a Labs credential minted at registration (B1) or at admin creation, and the passwordless door (B5) is the primary sign-in on every surface. Fix (D8, recorded, not applied in this wave): after the detached-instance repair above, point ProfileWithUsage.tsx at /api/auth/change-password with the current password, hide the card when has_password is false (GET /api/user/profile already reports it, user_router.py 66 and schemas/user_schema.py 27; no file under frontend/src reads that field of the user profile at fd8558f, the eight has_password hits there being the SMTP and IMAP cards' own field), delete the two user_router routes and the dict, and add the missing test for the surviving route.
  • 2026-09-12 (measured at fd8558f): forgot_password queues send_password_reset_email(email, token) with no session (891 to 896), so the password_reset template branch (email_service.py 776 to 783) is unreachable from the only route that sends resets, the same shape the magic link had before send_magic_link_email_task (B5 Gotchas, ceee5ac). An operator who edits the password_reset row in email_templates changes nothing. Fix: a send_password_reset_email_task that opens its own get_db_context().
  • 2026-09-12 (measured at fd8558f): no per-address throttle on /forgot-password. rate_limit.py's own comment (26 to 35) says the per-email ceiling "is the real anti-abuse control for magic links and password resets", and only the magic link calls it. Fix: a _password_reset_email_throttle dependency mirroring _magic_link_email_throttle (auth_router.py 1000 to 1004) with scope="password-reset".
  • 2026-09-12 (measured at fd8558f in identity-auth/rate-limiting, which owns the measurement; D8): the 429 from auth_rate_limit reaches the client without Retry-After on both public routes of this recipe. The dependency raises HTTPException(429, ..., headers={"Retry-After": str(max(1, retry_after))}) (api/dependencies/rate_limit.py 55 to 60), and http_exception_handler (app_fastapi.py 724 to 788) builds both of its JSONResponses with headers=_error_cors_headers(request) (754 and 779) and never reads exc.headers, so the header is dropped before the response leaves. B18 measured it with TestClient on the sibling auth routes: the denied request is 429 with the security headers, x-request-id and x-response-time present and no retry-after. The SPA's ForgotPassword card therefore has no countdown to render. Fix (D8, recorded, not applied in this wave): headers={**_error_cors_headers(request), **(exc.headers or {})} on both return sites, the one merge B18 specifies; the per-address throttle of the next Gotcha raises the same header and needs the same repair to be visible.
  • 2026-09-12 (measured at fd8558f): reset_password checks neither users.is_active nor the labs grant at redemption (996 to 1044); both are asked at request time only (create_password_reset 964 to 984). A disabled account can therefore set a new password from an unexpired link, which then fails at /login with 403. Not a session leak (no token is minted); a symmetry gap with verify_magic_link, which re-checks both.
  • 2026-09-12 (measured at fd8558f): change_password sets sessions.is_active = false but never denylists (845 to 877); reset_password does both (1026 to 1041). With JWT_DENYLIST_ENABLED on, a changed password leaves the old access tokens valid to expiry and a reset does not. The flag is off on the estate today (S5 Gotchas), so both behave the same in production; a new service revokes the same way on both routes.
  • 2026-09-12 (measured at fd8558f): tests/v2/test_f2_token_revocation.py and tests/test_critical_endpoints.py change colour with the state of the test database. Neither creates tables; the base test_engine fixture drops them all at the end of any session that used it (tests/conftest.py 102 to 108), and tests/test_surface_scoped_access.py recreates them (48 to 62). The same command printed 8 failed, 3 passed and 11 passed in one afternoon (Tests). A verifier who sees UndefinedTable: relation "workspaces" runs tests/test_surface_scoped_access.py first, or a file that ends in create_all, before opening a bug against the reset. Separately, and whichever colour the file has, no test at fd8558f calls reset_password with the denylist on: test_token_rejected_after_password_reset (147 to 156) exercises _denylist_token directly, so the loop at reset_password 1026 to 1034 is unmeasured. Fix: a fixture in the v2 file that requests the base test_engine, and a test that mints a session, calls reset_password with _denylist_enabled patched on, and asserts decode_token raises for the old access token.
  • 2026-09-12 (measured at fd8558f, code wins over the wiki): platform/architecture.md (6322fa3) says 2FA is "enabled by default" with six-digit codes and max three attempts. The 2FA routes in auth_router.py (1276 to 1508) are commented out; the only six-digit code in the auth surface is the user_router flow above, with no attempt counter.
  • 2026-08-02 (fb7159f, measured at fd8558f): the breach the passwordless check closes. create_password_reset looked the user up by email alone, so an unauthenticated request could hand an api_only account a password and turn it into a normal Labs login forever (the docstring at 952 to 962 records it as ADR-0020 prerequisite bugfix 2). test_password_reset_cannot_give_a_passwordless_account_a_password guards it.

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • backend/api/routers/auth_router.py: change_password (845 to 877), forgot_password (879 to 899), reset_password (901 to 919), the commented 2FA block (1276 to 1508); the mount at backend/app_fastapi.py 820.
    • backend/services/auth_service.py: hash_password and verify_password (346 to 364), _denylist_token (878 to 898), create_password_reset (951 to 994), reset_password (996 to 1044).
    • backend/models/user_model.py: PasswordReset (199 to 211), Session (157 to 184).
    • backend/schemas/auth_schemas.py: PasswordResetRequest (99 to 100), PasswordResetConfirm with validate_password (102 to 115), PasswordChange.
    • backend/services/email_service.py: send_password_reset_email (765 to 806), _send_email (855 to 882), _send_via_resend (1123 onward).
    • backend/api/routers/user_router.py: _password_change_codes (28 to 30), PasswordChangeCodeRequest and PasswordUpdateRequest (33 to 41), request_password_change_code (211 to 248), update_password (251 to 290); the mount at app_fastapi.py 875.
    • backend/api/dependencies/rate_limit.py: auth_rate_limit (38 to 60), enforce_email_rate_limit (63 to 92, uncalled here).
    • backend/api/dependencies.py 231 (get_current_user loads the caller through auth_service.get_user_by_id), backend/services/auth_service.py 1299 to 1305 (get_user_by_id inside get_db_context()), backend/core/database.py 444 to 461 (get_db_context, expunge_all() at 460): the detached-instance chain behind the first Gotcha; backend/api/routers/user_router.py 77 to 78 (the re-select that works), backend/api/routers/auth_router.py 822 to 843 (PATCH /me, the 500 on the same instance), backend/api/routers/admin_router.py 1463 onward (admin creation, the password at 1514 to 1515), backend/services/service_desk/access.py 640 and 657, backend/services/service_desk/signin.py 352, backend/services/content_review_service.py 107 (accounts created with password_hash=None and a labs grant).
    • backend/migrations/versions/baseline_prod_schema_001.py (1449 to 1459, 2616 to 2619, 3836 to 3838; table, primary key, unique index, no foreign key) and backend/migrations/versions/deferred_fk_phantom_tables_001.py (535 to 538 the cascade FK, 1059 its drop).
    • backend/core/config.py: TRUSTED_PROXY_COUNT (47), BCRYPT_ROUNDS (131, unread).
    • backend/app_fastapi.py: the HTTPException handler that writes the string error the SPA reads and drops exc.headers with it (724 to 788, the two JSONResponse return sites at 754 and 779; Gotchas); no RequestValidationError handler.
    • Dockerfile line 117 (--workers ${WEB_CONCURRENCY:-2}).
    • Tests as listed under Tests.
    • frontend/src/services/api.ts (requestPasswordReset 155 to 160, resetPassword 162 to 168, requestPasswordChangeCode 239, updatePassword 244 to 249), frontend/src/pages/settings/ProfileWithUsage.tsx (240 to 262), frontend/src/App.tsx (250 to 251), frontend/src/pages/ForgotPassword.tsx (40), frontend/src/pages/ResetPassword.tsx (checkPasswordStrength 55 to 67, validateForm 75 to 92, the submit and its fallback 94 to 119, navigate('/login') 110, the /login links 163 and 303).
  • alchemycloud (main, b3d34da): scripts/distribute-env.sh labs-prod heredoc (215 to 361; the Resend-beside-SMTP note at 262 to 273), key names only.
  • Wiki philosophers_stone (6322fa3): platform/architecture.md (the auth table), operations/prima-modules-inventory.md (row B6, D8, open question 2 of wave 1).

Deviations of the reference from this recipe, all dated 2026-09-12: both authenticated routes (change_password, update_password) assign the hash on the detached current_user and persist nothing, answering 204 and 200 while the old password still logs in; the second change-password flow with its process-local dict, transport bypass and missing revocation, and the profile page's dependence on it; the template branch unreachable from /forgot-password; no per-address throttle, and the Retry-After the per-IP limiter does raise is dropped by the HTTPException handler, so every 429 on the two public routes reaches the client without it; no is_active or grant re-check at redemption; change_password without the denylist; tokens stored verbatim and never purged; a revocation suite whose colour depends on test order and which never calls reset_password; BCRYPT_ROUNDS declared and unread; no test on the reset URL shape.

Acceptance checks

Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.

  1. python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -W ignore -k password_reset prints 1 passed, 43 deselected; python -m pytest tests/test_critical_endpoints.py -q --no-cov -o addopts="" -W ignore -k "TestPasswordResetEndpoints and (requires_email or requires_token_and_password)" prints 2 passed, 45 deselected (measured 2026-09-12 with the tables present and with them dropped; without the class name the selector also picks up the login and magic-link requires_email tests and prints 4 passed, 43 deselected).
  2. With the app running: curl -s -i -X POST http://127.0.0.1:8000/api/auth/forgot-password -H 'Content-Type: application/json' -d '{"email":"[email protected]"}' shows 202 and "If the email exists, a password reset link has been sent"; the same for an address whose password_hash IS NULL shows the same 202 and the log shows Refusing password reset for passwordless account; the same for a user holding prima_chat only shows the same 202 and no labs grant.
  3. python -c "from services.auth_service import auth_service as a; print(a.create_password_reset('<labs user with a password>'))" prints a 43-character token; SELECT used, expires_at > now() FROM password_resets WHERE token = '<t>' shows f | t.
  4. curl -s -i -X POST http://127.0.0.1:8000/api/auth/reset-password -H 'Content-Type: application/json' -d '{"token":"<t>","new_password":"weak"}' shows 422 and Password must contain at least one uppercase letter or the length error; with "new_password":"Correct1horse" it shows 204; the same request again shows 400 and Invalid or expired reset token. Then POST /api/auth/login with Correct1horse shows 200 and with the previous password 401. Repeat step 3 and, before redeeming, UPDATE users SET is_active = false WHERE id = :u: at fd8558f the redemption still shows 204 and the hash changes (measured 2026-09-12 with the hermetic client; the deviation named in Gotchas); a service built to this recipe shows 400.
  5. Before step 4, mint a session for the user with POST /api/auth/login; after the 204, POST /api/auth/refresh with that refresh token is 401, and SELECT count(*) FROM sessions WHERE user_id = :u AND is_active prints 0. With JWT_DENYLIST_ENABLED=true and Redis up, GET /api/auth/me with the old access token is also 401 immediately; with the flag off it stays 200 until the token expires (the documented default).
  6. curl -s -i -X POST http://127.0.0.1:8000/api/auth/reset-password -H 'Content-Type: application/json' -d '{"token":"'$(python -c 'import secrets;print(secrets.token_urlsafe(32))')'","new_password":"Correct1horse"}' shows 400 (an unknown token is indistinguishable from an expired one).
  7. Thirty-one POST /forgot-password requests from one IP within a minute show 429 on the thirty-first; at fd8558f that response carries no retry-after among its headers (the handler drops the one the dependency raises; the deviation is measured in identity-auth/rate-limiting Gotchas), and on a service built to this recipe it carries retry-after. Thirty-one requests for one address from rotating IPs show no 429 (the deviation named in Gotchas; a new service built to this recipe shows 429 on the eleventh).
  8. grep -rn "change-password" frontend/src --include='*.ts' --include='*.tsx' | wc -l prints 0 and grep -rn "user/password" frontend/src/services/api.ts | wc -l prints 2 at fd8558f; on a service built to this recipe the two numbers are reversed in spirit: one caller of /api/auth/change-password, none of /api/user/password.
  9. grep -c "_password_change_codes" backend/api/routers/user_router.py prints 5 at fd8558f and 0 on a service built to this recipe.
  10. With a session for a verified labs user whose password is correct horse battery: curl -s -i -X POST http://127.0.0.1:8000/api/auth/change-password -H "Authorization: Bearer <access>" -H 'Content-Type: application/json' -d '{"current_password":"correct horse battery","new_password":"Correct1horse"}' shows 204; then POST /api/auth/login with Correct1horse. At fd8558f it shows 401 and the old password shows 200 (measured 2026-09-12; SELECT password_hash FROM users WHERE id = :u is byte-identical before and after, and SELECT count(*) FROM sessions WHERE user_id = :u AND is_active is 0). On a service built to this recipe the new password shows 200 and the old one 401. The same walk through POST /api/user/password/request-code and PUT /api/user/password with the code shows 200, 200 and the same unchanged hash at fd8558f; on a service built to this recipe the two routes do not exist (check 9).
  11. grep -n "expunge_all" backend/core/database.py prints a line inside get_db_context at fd8558f (460), and grep -n "get_user_by_id(user_id)" backend/api/dependencies.py prints 231: the two facts behind check 10. A service built to this recipe either loads the caller in the request's own session or re-selects before every write.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.