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 |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
PASSWORD_RESET_EXPIRE_HOURS | 6 | expires_at = utcnow + hours on the row (auth_service.py 986 to 991) |
FRONTEND_URL | http://localhost:3000 | the 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 B5 | transport_is_configured() gates the send; nothing configured is 202 and no mail |
JWT_DENYLIST_ENABLED | off | not 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_COUNT | 1 | the 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
| Route | Auth dep | Tenancy | Codes | Error codes and bodies |
|---|---|---|---|---|
POST /api/auth/forgot-password | none; auth_rate_limit (30 per minute per client IP, keyed by path) | none | 202 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-password | none; auth_rate_limit | none | 204 | 400 "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-password | ActiveUser | none | 204 (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-code | ActiveUser; mounted on LABS_SURFACES | none | 200 {"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/password | ActiveUser; LABS_SURFACES | none | 200 {"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:
auth_rate_limit(per IP).auth_service.create_password_reset(email)(auth_service.py951 to 994): in its own session, load the user by lowercased email;Nonewhen missing;Nonewith a WARNING (Refusing password reset for passwordless account <id>) whenpassword_hash IS NULL;Nonewith a WARNING (Refusing password reset for user <id>: no labs grant) whenlabsis not inget_user_surfaces(db, user.id); otherwise insert a row withtoken = secrets.token_urlsafe(32)andexpires_at = utcnow + PASSWORD_RESET_EXPIRE_HOURS, commit, return the token.is_activeis not consulted at request time.- 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 withdb=Noneand thepassword_resetrow inemail_templatesis 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 whendbis given). - Return 202 with the fixed message.
reset_password (auth_router.py 901 to 919):
auth_rate_limit; the body validator has already refused a weak password with 422.auth_service.reset_password(token, new_password)(auth_service.py996 to 1044): in its own session, select the row wheretokenmatches,used == Falseandexpires_at > utcnow;Falseotherwise. Load the user (scalar_one), setpassword_hash = hash_password(new_password), mark the rowusedwithused_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). ThenUPDATE sessions SET is_active = false WHERE user_id = :u(1037 to 1041), commit,True.Falseis 400;Trueis 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 NULLand alabsgrant (create_password_reset971 to 984;test_password_reset_cannot_give_a_passwordless_account_a_password,tests/test_surface_scoped_access.py530). POST /forgot-passwordanswers 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_HOURSafter mint;used,used_atand 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/loginand the old one answers 401. The reference at fd8558f holds this forreset_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-passwordor a 200 fromPUT /api/user/passwordthe old password still logs in and the new one is 401. - At redemption a service built to this recipe re-checks
users.is_activeand thelabsgrant and answers 400 when either fails, asverify_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_password1026 to 1034). No test at fd8558f callsreset_passwordwith the denylist on:test_token_rejected_after_password_reset(tests/v2/test_f2_token_revocation.py147 to 156) calls_denylist_tokendirectly and proves only that a denylistedjtiis 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.py102 to 115) on the reset,PasswordChange(the same three rules plusNew 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 thelabsgrant. - The reset link's base is
FRONTEND_URL, the one place the Labs SPA lives (email_service.py770 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_reseton anapi_onlyaddress returnsNoneandusers.password_hashstaysNULL.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 areenable_denylistandfake_redis, 75 to 85), butcreate_access_tokenresolvestid/tids_nthrough_resolve_tenant_claims, which opensget_db_context()when no session is passed (auth_service.py278 to 298), so eight of its eleven tests need aworkspacestable that the file never creates. The basetest_enginefixture (tests/conftest.py56 to 108, session scope) drops every table when its session ends, andtests/test_surface_scoped_access.pycreates them and never drops (48 to 62). Measured 2026-09-12: run right aftertests/test_api_endpoints.py -k test_update_user_settings(which requestsdb_session) it prints8 failed, 3 passed, every failurepsycopg2.errors.UndefinedTable: relation "workspaces" does not exist; run right aftertests/test_surface_scoped_access.pyit prints11 passed. Green or red,test_token_rejected_after_password_reset(147 to 156) never callsreset_password: it mints a token, callsAuthService._denylist_token(token, token_type="access")itself and asserts the decode fails. The reset's own denylist loop (reset_password1026 to 1034) has no test at fd8558f.test_critical_endpoints.py::TestPasswordResetEndpoints(394 to 445): the same order dependence, since the file builds aTestClienton a module-scopedapp(34 to 44) and creates no tables. Measured 2026-09-12:14 failed, 33 passedafter thedrop_allabove (test_forgot_password_endpoint_existsandtest_reset_password_rejects_invalid_tokenamong the failures, allUndefinedTable),10 failed, 37 passedwith the tables present.test_forgot_password_requires_email(410) andtest_reset_password_requires_token_and_password(433) pass in both states: the 422s are real. Note for-k:requires_emailalso matchesTestAuthEndpoints::test_login_requires_email(154) andTestMagicLinkEndpoints::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_URLis mocked to a placeholder and never asserted against, so the URL shape<FRONTEND_URL>/reset-password?token=<t>(email_service.py770 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-passwordor the twouser_routerroutes as built:grep -rln "change-password\|password/request-code\|/api/user/password" testshits onlytests/test_api_endpoints.py, whosetest_update_user_settings(189 to 195) still sendscurrent_passwordtoPUT /api/user/passwordand expects 200; measured 2026-09-12 it prints1 failedwithassert 422 == 200, because the route has takenverification_codesince 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.
ActiveUserresolves throughget_current_user, which loads the row withauth_service.get_user_by_id(api/dependencies.py231;auth_service.py1299 to 1305) insideget_db_context(), whosefinallycallssession.expunge_all()before closing (core/database.py460), so every handler receivescurrent_useras a detached instance (sqlalchemy.inspect(auth_service.get_user_by_id(id)).detachedprintsTrue).change_password(auth_router.py865 to 866) andupdate_password(user_router.py286 to 287) assignpassword_hashon that detached object and commit the route's ownDatabaseSession, which holds no change. Measured with a hermeticTestClientagainst the:5434test Postgres on a verifiedlabsuser:POST /api/auth/change-passwordwith the correct current password answers 204,SELECT password_hash FROM usersafterexpire_all()is unchanged,POST /api/auth/loginwith the old password is 200 and with the new one 401;POST /api/user/password/request-codeanswers 200,PUT /api/user/passwordwith the code read out of_password_change_codesanswers 200Password updated successfully, the hash is unchanged and the new password is 401 at login. The session UPDATE inchange_password(869 to 875) is raw SQL ondband 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 mutatescurrent_userthe same way and then callsdb.refresh(current_user), answers 500 on the detached instance;user_router's working profile update re-selects the user indbfirst (77 to 78);reset_passwordwrites the hash on a user its own session loaded (auth_service.py1014 to 1018) and is the one route of the four that changes the password. Fix (D8, recorded, not applied in this wave): re-select theUserin the route'sdbbefore assigning, the pattern atuser_router.py77 to 78 (ordb.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-codeandPUT /api/user/password(user_router.py211 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 (Dockerfile117) the PUT lands on the worker that holds the code only half the time and the other half answers 400No verification code requested; the send callsemail_service._send_via_resenddirectly, gated onsettings.RESEND_API_KEY(229 to 244), bypassingEMAIL_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.ts239 to 249), whilePOST /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.py640,password_hash=None, grantedlabsat 657), the desk sign-in's account (services/service_desk/signin.py352), reviewer accounts (services/content_review_service.py107), and magic-link registrations. After the fix below those accounts have no self-service route to a first password:change_passwordanswers 400 on aNULLhash,forgot-passwordrefuses them by design (Purpose), and the only writers of a hash on an existing row are B1'sset_password_with_verification(auth_service.py1219 to 1248, a verification token) andreset_password; an admin sets a password at creation only (admin_router.py1463 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, pointProfileWithUsage.tsxat/api/auth/change-passwordwith the current password, hide the card whenhas_passwordis false (GET /api/user/profilealready reports it,user_router.py66 andschemas/user_schema.py27; no file underfrontend/srcreads that field of the user profile at fd8558f, the eighthas_passwordhits there being the SMTP and IMAP cards' own field), delete the twouser_routerroutes and the dict, and add the missing test for the surviving route. - 2026-09-12 (measured at fd8558f):
forgot_passwordqueuessend_password_reset_email(email, token)with no session (891 to 896), so thepassword_resettemplate branch (email_service.py776 to 783) is unreachable from the only route that sends resets, the same shape the magic link had beforesend_magic_link_email_task(B5 Gotchas, ceee5ac). An operator who edits thepassword_resetrow inemail_templateschanges nothing. Fix: asend_password_reset_email_taskthat opens its ownget_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_throttledependency mirroring_magic_link_email_throttle(auth_router.py1000 to 1004) withscope="password-reset". - 2026-09-12 (measured at fd8558f in
identity-auth/rate-limiting, which owns the measurement; D8): the 429 fromauth_rate_limitreaches the client withoutRetry-Afteron both public routes of this recipe. The dependency raisesHTTPException(429, ..., headers={"Retry-After": str(max(1, retry_after))})(api/dependencies/rate_limit.py55 to 60), andhttp_exception_handler(app_fastapi.py724 to 788) builds both of itsJSONResponses withheaders=_error_cors_headers(request)(754 and 779) and never readsexc.headers, so the header is dropped before the response leaves. B18 measured it withTestClienton the sibling auth routes: the denied request is 429 with the security headers,x-request-idandx-response-timepresent and noretry-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_passwordchecks neitherusers.is_activenor thelabsgrant at redemption (996 to 1044); both are asked at request time only (create_password_reset964 to 984). A disabled account can therefore set a new password from an unexpired link, which then fails at/loginwith 403. Not a session leak (no token is minted); a symmetry gap withverify_magic_link, which re-checks both. - 2026-09-12 (measured at fd8558f):
change_passwordsetssessions.is_active = falsebut never denylists (845 to 877);reset_passworddoes both (1026 to 1041). WithJWT_DENYLIST_ENABLEDon, 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.pyandtests/test_critical_endpoints.pychange colour with the state of the test database. Neither creates tables; the basetest_enginefixture drops them all at the end of any session that used it (tests/conftest.py102 to 108), andtests/test_surface_scoped_access.pyrecreates them (48 to 62). The same command printed8 failed, 3 passedand11 passedin one afternoon (Tests). A verifier who seesUndefinedTable: relation "workspaces"runstests/test_surface_scoped_access.pyfirst, or a file that ends increate_all, before opening a bug against the reset. Separately, and whichever colour the file has, no test at fd8558f callsreset_passwordwith the denylist on:test_token_rejected_after_password_reset(147 to 156) exercises_denylist_tokendirectly, so the loop atreset_password1026 to 1034 is unmeasured. Fix: a fixture in the v2 file that requests the basetest_engine, and a test that mints a session, callsreset_passwordwith_denylist_enabledpatched on, and assertsdecode_tokenraises 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 inauth_router.py(1276 to 1508) are commented out; the only six-digit code in the auth surface is theuser_routerflow above, with no attempt counter. - 2026-08-02 (fb7159f, measured at fd8558f): the breach the passwordless check closes.
create_password_resetlooked the user up by email alone, so an unauthenticated request could hand anapi_onlyaccount 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_passwordguards it.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):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 atbackend/app_fastapi.py820.backend/services/auth_service.py:hash_passwordandverify_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),PasswordResetConfirmwithvalidate_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),PasswordChangeCodeRequestandPasswordUpdateRequest(33 to 41),request_password_change_code(211 to 248),update_password(251 to 290); the mount atapp_fastapi.py875.backend/api/dependencies/rate_limit.py:auth_rate_limit(38 to 60),enforce_email_rate_limit(63 to 92, uncalled here).backend/api/dependencies.py231 (get_current_userloads the caller throughauth_service.get_user_by_id),backend/services/auth_service.py1299 to 1305 (get_user_by_idinsideget_db_context()),backend/core/database.py444 to 461 (get_db_context,expunge_all()at 460): the detached-instance chain behind the first Gotcha;backend/api/routers/user_router.py77 to 78 (the re-select that works),backend/api/routers/auth_router.py822 to 843 (PATCH /me, the 500 on the same instance),backend/api/routers/admin_router.py1463 onward (admin creation, the password at 1514 to 1515),backend/services/service_desk/access.py640 and 657,backend/services/service_desk/signin.py352,backend/services/content_review_service.py107 (accounts created withpassword_hash=Noneand alabsgrant).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) andbackend/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: theHTTPExceptionhandler that writes the stringerrorthe SPA reads and dropsexc.headerswith it (724 to 788, the twoJSONResponsereturn sites at 754 and 779; Gotchas); noRequestValidationErrorhandler.Dockerfileline 117 (--workers ${WEB_CONCURRENCY:-2}).- Tests as listed under Tests.
frontend/src/services/api.ts(requestPasswordReset155 to 160,resetPassword162 to 168,requestPasswordChangeCode239,updatePassword244 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(checkPasswordStrength55 to 67,validateForm75 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.shlabs-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.
python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -W ignore -k password_resetprints1 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)"prints2 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-linkrequires_emailtests and prints4 passed, 43 deselected).- 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]"}'shows202and"If the email exists, a password reset link has been sent"; the same for an address whosepassword_hash IS NULLshows the same202and the log showsRefusing password reset for passwordless account; the same for a user holdingprima_chatonly shows the same202andno labs grant. 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>'showsf | t.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"}'shows422andPassword must contain at least one uppercase letteror the length error; with"new_password":"Correct1horse"it shows204; the same request again shows400andInvalid or expired reset token. ThenPOST /api/auth/loginwithCorrect1horseshows200and with the previous password401. Repeat step 3 and, before redeeming,UPDATE users SET is_active = false WHERE id = :u: at fd8558f the redemption still shows204and the hash changes (measured 2026-09-12 with the hermetic client; the deviation named in Gotchas); a service built to this recipe shows400.- Before step 4, mint a session for the user with
POST /api/auth/login; after the 204,POST /api/auth/refreshwith that refresh token is401, andSELECT count(*) FROM sessions WHERE user_id = :u AND is_activeprints0. WithJWT_DENYLIST_ENABLED=trueand Redis up,GET /api/auth/mewith the old access token is also401immediately; with the flag off it stays200until the token expires (the documented default). 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"}'shows400(an unknown token is indistinguishable from an expired one).- Thirty-one
POST /forgot-passwordrequests from one IP within a minute show429on the thirty-first; at fd8558f that response carries noretry-afteramong its headers (the handler drops the one the dependency raises; the deviation is measured inidentity-auth/rate-limitingGotchas), and on a service built to this recipe it carriesretry-after. Thirty-one requests for one address from rotating IPs show no429(the deviation named in Gotchas; a new service built to this recipe shows429on the eleventh). grep -rn "change-password" frontend/src --include='*.ts' --include='*.tsx' | wc -lprints0andgrep -rn "user/password" frontend/src/services/api.ts | wc -lprints2at 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.grep -c "_password_change_codes" backend/api/routers/user_router.pyprints5at fd8558f and0on a service built to this recipe.- With a session for a verified
labsuser whose password iscorrect 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"}'shows204; thenPOST /api/auth/loginwithCorrect1horse. At fd8558f it shows401and the old password shows200(measured 2026-09-12;SELECT password_hash FROM users WHERE id = :uis byte-identical before and after, andSELECT count(*) FROM sessions WHERE user_id = :u AND is_activeis0). On a service built to this recipe the new password shows200and the old one401. The same walk throughPOST /api/user/password/request-codeandPUT /api/user/passwordwith the code shows200,200and the same unchanged hash at fd8558f; on a service built to this recipe the two routes do not exist (check 9). grep -n "expunge_all" backend/core/database.pyprints a line insideget_db_contextat fd8558f (460), andgrep -n "get_user_by_id(user_id)" backend/api/dependencies.pyprints231: 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.