Skip to main content

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) and PasswordResetController, in the access-control engine; the row is password_reset_token (migration accesscontrol/028).
  • Applies to: local authentication only. With KEYCLOAK_ENABLED=true the admin reset refuses with 400 "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

StepEndpointCallerAnswer
IssuePOST /api/v1/access-control/user-accounts/{userAccountId}/reset-passwordan administrator holding accesscontrol.user.write, scoped to the account's tenant200 with the account — never any credential material
Request (SEC-030)POST /api/v1/auth/password-reset/requestanonymous — from the sign-in screen204, always
RedeemPOST /api/v1/auth/password-reset/redeemanonymous — the link holder, who by definition cannot sign in204

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.

StatusWhen
204 No ContentThe password is set and the token is spent.
400 Bad RequestThe 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 UnauthorizedThe 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 RequestsRate 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 named username, 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 RateLimitProperties over setting security.rate-limit.paths in 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

ColumnNotes
token_hashSHA-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_atexpires_at is issued_at + token-ttl; the index on it supports expiry sweeps.
redeemed_atNon-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_idStamped 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 varPropertyDefaultMeaning
LOCAL_AUTH_PASSWORD_RESET_TOKEN_TTLlocal-auth.password-reset.token-ttlPT60MHow 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_URLlocal-auth.password-reset.redeem-urlhttp://localhost:5173/password-resetThe 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:

  1. Put it under /api/v1/auth/** and add the exact path string to both AUTH_PUBLIC_PATHS and RateLimitProperties.DEFAULT_PATHS. One without the other fails open.
  2. Make its refusal a 401 or a 403, or the rate limiter will not count it. Check AuthRateLimitFilter.isAuthenticationFailure before choosing a status, not after.
  3. Validate the caller-supplied, non-secret parts first, so a validation status never doubles as confirmation that the secret was accepted.
  4. Give every refusal one identical body, and log the real reason server-side.
  5. Test it anonymously. A slice test that authenticates proves nothing about a path whose entire point is that it is reachable without a session — PasswordResetControllerTest asserts both that redeem succeeds with no authentication and that its neighbours still refuse anonymous callers.