Password reset issues a one-time link, not a temporary password (SEC-010)
An administrator starting a password reset used to have the server generate a temporary password,
set it as the account's password, and interpolate it into a notification body. That body is
notification_event.body — a persisted TEXT column with no redaction and no purge — so every
temporary password the platform ever issued stayed readable in the database indefinitely, and the
holder's real credential had already been changed by somebody who is not the holder.
Now the reset mints a one-time token, stores only its SHA-256 hash, and the account's password is untouched until its holder redeems the link at a new unauthenticated endpoint. What is written down is a secret that stops working on first use or on expiry, whichever comes first, and that has never been the account's password. Anyone reading the stored body after either event holds nothing.
- Owned by:
UserAccountServiceImpl(resetPassword,redeemPasswordReset) andPasswordResetController, in the access-control engine; the row ispassword_reset_token(migrationaccesscontrol/028). - Applies to: local authentication only. With
KEYCLOAK_ENABLED=truethe admin reset refuses with400 "Password reset is managed by the identity provider", because the credential is not ours. - Precedent:
user_refresh_token(accesscontrol/003) already stored a hash rather than a token. This is the same shape applied to the one credential path that still wrote a secret down.
:::note SEC-022 — registration now uses this same mechanism
Registration used to generate a password, set it on the account and write it in cleartext into
notification_event.body. Everything this page says about why that was unacceptable applied to it
unchanged.
It no longer does. PersonRegistrationServiceImpl mints the same one-time link described below and
puts it in the notification body; the account's password is something only its holder ever knows.
⚠ Registration also returns that link in its API response, and that is deliberate: nothing dispatches notifications yet (NOTIF-001), so it is currently the only way the credential reaches its owner — the registering clerk hands it over. When dispatch works, only the returned value changes.
⚠ This is the one place a link is returned to a caller, and the reason is narrow. Registration creates the account, so at that instant the registering clerk already controls it and there is no prior owner to take over from. The administrator reset below returns no credential material at all, because there a link in the wrong hands is an account takeover.
The rows written before this fix are redacted by notification/007-redact-legacy-credential-bodies.yaml.
:::
The halves of the flow
| Step | Endpoint | Caller | Answer |
|---|---|---|---|
| Issue | POST /api/v1/access-control/user-accounts/{userAccountId}/reset-password | an administrator holding accesscontrol.user.write, scoped to the account's tenant | 200 with the account — never any credential material |
| Request (SEC-030) | POST /api/v1/auth/password-reset/request | anonymous — from the sign-in screen | 204, always |
| Redeem | POST /api/v1/auth/password-reset/redeem | anonymous — the link holder, who by definition cannot sign in | 204 |
Requesting one yourself
Until SEC-030 only an administrator could start a reset, so anyone who forgot their password had to reach a colleague before they could reach the system. The sign-in screen now offers a request form that posts a username or email address.
:::danger The response never says whether the account exists
Unknown username, disabled account, no email on file, a Keycloak deployment that does not own the
credential: one identical empty 204 for every one of them, and one confirmation on screen worded
as a condition ("if that account exists…").
An endpoint open to the internet that distinguishes those cases is an account-enumeration oracle, and
the list of real usernames it yields is the input to the credential-stuffing run that follows. The
service returns void so no status can be derived at the controller, and both the service tests and
the screen's tests pin it. Do not "improve" the error handling here.
:::
Two consequences worth knowing:
- The link is never returned to the caller. Registration returns one (SEC-022) because the caller just created the account and already controls it. This caller is anonymous and has proved nothing; returning it would make type a username, take the account the supported flow. Where notification dispatch is not configured, the honest outcome is that the link cannot be delivered.
- The mail goes to the address on the account, never to one supplied in the request.
The request is recorded in security_event as CREDENTIAL_RESET_REQUESTED — but only when an
account was found. A row per miss would put the very question the endpoint refuses to answer into a
table somebody can query, which moves the oracle rather than closing it. Since the responses are
identical by design, that log is the only place a burst walking a list of usernames is visible.
Issuing a link retires the account's earlier unspent ones: they are voided (and their expires_at
pulled back to now), so a second reset revokes the first rather than leaving two live doors. They are
voided rather than deleted because the row is the audit record that a reset was started —
@SQLRestriction("voided = false") plus a partial unique index on token_hash together mean a
retired token can neither be found by a lookup nor block a future hash.
Redemption sets the new password, burns the token in the same transaction, and clears
failed_login_attempts / locked_until: someone who has just proved control of the account's mailbox
should not still be locked out by the failures that led them there.
The redeem contract
POST /api/v1/auth/password-reset/redeem, body {token, newPassword}. Both fields are WRITE_ONLY
and neither is ever echoed back.
| Status | When |
|---|---|
204 No Content | The password is set and the token is spent. |
400 Bad Request | The new password is missing or fails policy — 8–128 characters, at least one letter and one digit. The message is specific, because nothing about a password the caller just typed is a secret we are keeping from them. |
401 Unauthorized | The token is unknown, or expired, or already redeemed — with one identical body in all three cases: "Password reset link is invalid or has expired." |
429 Too Many Requests | Rate limited, with a Retry-After header. |
:::warning The three 401s are byte-identical on purpose Unknown, expired and spent must stay indistinguishable. Telling the caller which one it was confirms that a guessed token was once real, and "real but expired" is a very different starting position for an attacker than "never existed". Any field that later differs between those three answers rebuilds the distinction somewhere nobody is looking — the same guarantee, and the same fragility, as the record-denial convention. :::
Four things that are less obvious than they look
The 401 is load-bearing, not stylistic
A bad token is a bad request, and the house exception for that is InvalidRequestException, which
answers 400. It answers 401 instead because AuthRateLimitFilter.isAuthenticationFailure counts
only 401 and 403 (M33-002A — see authentication rate limiting).
Had a refused token answered 400, the limiter would never have counted it: the path would sit in
RateLimitProperties.DEFAULT_PATHS, the configuration would look complete, and token guessing would
cost an attacker nothing. The co-requisite would have been present in config and inert in reality.
That is why the refusal raises AuthenticationFailedException rather than InvalidRequestException.
Changing that status is not a cosmetic change to an error contract — it silently disarms the only
throttle on an unauthenticated write.
The new password is validated before the token is looked up
The order in redeemPasswordReset is load-bearing. Validate the token first and a 400 would only
ever be reachable after a token was accepted — so 400 would mean "your token was good, your
password was not", and 401 would mean "your token was bad". The status code alone would then answer
"did this token exist?", which is precisely what the identical 401 exists to hide.
With the password judged first, both answers are reachable from a wrong token, and neither says which
half of the request failed. A weak password answers 400 even when the token is unknown — that is
what UserAccountServiceImplRedeemTest and PasswordResetControllerTest pin.
One nuance: an absent or blank token is caught by bean validation (@NotBlank) before the
service runs, so it answers 400. That is not an oracle — an empty string is not a guess, and it
distinguishes nothing about tokens that do exist.
The path spelling is itself a security control
SecurityConfig.AUTH_PUBLIC_PATHS and RateLimitProperties.DEFAULT_PATHS both match by exact
string (List.contains, no pattern matching). A path present in the first and misspelled in the
second is anonymous and unthrottled, and it fails open: everything works, nothing logs a
warning, and the only symptom is that the limiter never fires.
This is why the endpoint lives under /api/v1/auth/** with the rest of the anonymous surface rather
than under /api/v1/access-control/… where the rest of the engine sits. Keeping the whole public
surface under one prefix is what keeps the two lists reviewable side by side — the invariant is
"these two lists agree, character for character", and a prefix that only ever holds public paths makes
a divergence obvious to a reader.
Two related traps:
- The redeem path is deliberately not in
usernamePaths. That bucket parses a JSON field literally namedusername, which a{token, newPassword}body does not carry, so the per-(IP, username) bucket cannot apply — only the per-IP one does. - Prefer changing the defaults in
RateLimitPropertiesover settingsecurity.rate-limit.pathsin YAML. Spring list binding replaces rather than appends, so a YAML list written to add one path silently drops the others from the limiter.
The failure reason is audited even though it is not returned
refuseRedemption logs the true reason — no token presented, no live token matches, already redeemed,
expired — with the token row's id (never its hash), and then returns the one generic exception. An
operator investigating a burst of failures needs exactly the distinction the caller must not have:
"twelve unknown tokens from one address" and "one expired link retried twelve times" call for
different responses.
This is the same asymmetry AccessDecisionServiceImpl.withhold draws, and it is the general rule for
this platform: the server records why, the caller learns only that.
What is stored
| Column | Notes |
|---|---|
token_hash | SHA-256 hex of the token, via LocalAuthService.hashToken — the single digest implementation in the package. The raw token (48 random bytes, hex) exists only in the link. |
issued_at / expires_at | expires_at is issued_at + token-ttl; the index on it supports expiry sweeps. |
redeemed_at | Non-null means spent. The single-use marker, so a link that reaches a second reader — a forwarded mail, a proxy log, a shared inbox — is inert. |
tenant_id / facility_id | Stamped from the account and the issuing session. |
The hash is deliberately not PiiProtector-encrypted. A random-IV envelope cannot be looked up by
exact match, and pii.encryption.enabled defaults to false, so an
encrypted column would behave differently per environment. user_refresh_token.token_hash is the
precedent.
Configuration
| Env var | Property | Default | Meaning |
|---|---|---|---|
LOCAL_AUTH_PASSWORD_RESET_TOKEN_TTL | local-auth.password-reset.token-ttl | PT60M | How long a link stays usable. Short on purpose — the link is carried in a persisted notification body, and this is the window in which that stored copy is worth anything. |
LOCAL_AUTH_PASSWORD_RESET_REDEEM_URL | local-auth.password-reset.redeem-url | http://localhost:5173/password-reset | The browser page the link points at; the token is appended as ?token=. Configured rather than derived, because the API host and the browser host are not the same name in any real deployment. |
Both are set in core and app application.yml and must stay in step.
:::danger An enqueued reset link is not delivered today
resetPassword enqueues a notification event; nothing sends it. There is no scheduler in the
notification engine, and sendEvent is reachable only from
POST /api/v1/notification/notification-events/{id}/send, an admin endpoint behind
notification.event.write. So a reset link is queued and waits until somebody dispatches it by hand.
Do not build, document or demo anything on the assumption that an email arrives. This is a known gap, ticketed separately. Until it closes, an operator completing a reset has to send the queued event deliberately — and the TTL above is counting from the moment the link was issued, not from the moment it was sent.
An account with no email address on file is skipped entirely: the token is still minted and stored, and nothing is enqueued at all. :::
Adding another unauthenticated endpoint
There is now exactly one anonymous write, and the next one should be argued for rather than added. If it is, everything above has to be repeated deliberately:
- Put it under
/api/v1/auth/**and add the exact path string to bothAUTH_PUBLIC_PATHSandRateLimitProperties.DEFAULT_PATHS. One without the other fails open. - Make its refusal a 401 or a 403, or the rate limiter will not count it. Check
AuthRateLimitFilter.isAuthenticationFailurebefore choosing a status, not after. - Validate the caller-supplied, non-secret parts first, so a validation status never doubles as confirmation that the secret was accepted.
- Give every refusal one identical body, and log the real reason server-side.
- Test it anonymously. A slice test that authenticates proves nothing about a path whose entire
point is that it is reachable without a session —
PasswordResetControllerTestasserts both that redeem succeeds with no authentication and that its neighbours still refuse anonymous callers.
Related
- Access control — the engine that owns the accounts and the reset.
- Authentication rate limiting — the throttle the 401 feeds, and the
path lists that must agree with
SecurityConfig. - Notification — where the link is enqueued, and why it does not leave yet.
- Denied record reads answer as not-found — the same "one answer for several causes" discipline applied to record reads.