Two-step password reset flow: first request a reset token by email, then submit the new password using the token. Public access without authentication.
- Step 1 always returns 200 OK regardless of whether the email exists — prevents user enumeration.
- The reset token is single-use and expires after 1 hour.
passwordandpasswordConfirmationmust match and meet the minimum length requirement.- After generating the reset token, the backend sends a password reset e-mail via
EmailSender(see ADR-025).productionprofile: real e-mail sent via Mailgun fromnoreply@mail.talkide.app.!productionprofile (NoOpEmailSender): no e-mail is sent; the full reset link is logged to the console at INFO level (same visibility as MVP behavior).
- E-mail sending failure does not affect the API response — the endpoint always returns 200 OK even if the
EmailSenderthrows. The failure is logged and recorded inemail_logwithstatus=FAILED.
sequenceDiagram
actor User
User->>+FE: fills Forgot Password form (email)
FE->>FE: validate form
alt form is invalid
FE-->>User: show error messages <br> disable submit button
end
User->>+FE: submits Forgot Password form
FE->>+BE: POST /api/v1/auth/forgot-password <br> ForgotPasswordRequest
BE->>BE: validate request
alt request is invalid
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: find user by email (silently ignore if not found)
BE->>DB: create password reset token (if user exists)
BE->>+BE: send password reset e-mail via EmailSender <br> (production: Mailgun; !production: log link to console)
BE-->>-BE: result logged; failure does not affect response
BE->>-FE: 200 OK (always)
FE->>-User: show "If the email exists, a reset link was sent" message
User->>+FE: opens reset link, fills Reset Password form
FE->>FE: validate form
alt form is invalid
FE-->>User: show error messages <br> disable submit button
end
User->>+FE: submits Reset Password form
FE->>+BE: POST /api/v1/auth/reset-password <br> ResetPasswordRequest
BE->>BE: validate request
alt request is invalid
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: find reset token record
alt token not found or expired
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>BE: verify passwordConfirmation matches password
alt passwords do not match
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: update user password hash
BE->>DB: delete (invalidate) reset token
BE->>-FE: 204 No Content
FE->>-User: show "Password changed" message, redirect to login
Step 1 — Request Password Reset
POST /api/v1/auth/forgot-password ForgotPasswordRequest:
{
"email": "jane.doe@example.com"
}
200 OK (always, no body)
400 Bad Request (validation) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "must be a valid email address" }
]
}
Step 2 — Reset Password
POST /api/v1/auth/reset-password ResetPasswordRequest:
{
"token": "a1b2c3d4-e5f6-...",
"password": "newSecret123",
"passwordConfirmation": "newSecret123"
}
204 No Content (success, no body)
400 Bad Request (validation) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "password", "message": "size must be between 8 and 72" }
]
}
400 Bad Request (invalid or expired token) ErrorResponse:
{
"status": 400,
"code": "INVALID_RESET_TOKEN",
"message": "Password reset token is invalid or expired"
}
Frontend
Step 1 — Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| not_blank | ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ |
Step 2 — Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| token | not_blank | read from URL query parameter | ||
| password | not_blank | 8 - 72 | ||
| passwordConfirmation | not_blank | must match password (client-side check) |
Backend
Step 1 — Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| not_blank, email |
Step 2 — Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| token | not_blank | |||
| password | not_blank | 8 - 72 | ||
| passwordConfirmation | not_blank | must equal password (domain validation) |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| email belongs to existing user | forgot-password is called | 200 OK, reset token created in DB, password reset e-mail sent (production: Mailgun, !production: link logged to console), email_log entry created with status=SENT |
| email does not belong to any user | forgot-password is called | 200 OK (no token created, no error exposed) |
| invalid email format | forgot-password is called | 400 VALIDATION_ERROR error response is returned |
| email field is blank | forgot-password is called | 400 VALIDATION_ERROR error response is returned |
| valid token, matching passwords | reset-password is called | 204 No Content, password updated, token deleted from DB |
| token not found in DB | reset-password is called | 400 INVALID_RESET_TOKEN error response is returned |
| token is expired | reset-password is called | 400 INVALID_RESET_TOKEN error response is returned |
| password shorter than 8 characters | reset-password is called | 400 VALIDATION_ERROR error response is returned |
| passwordConfirmation does not match password | reset-password is called | 400 VALIDATION_ERROR error response is returned |
| invalid request (empty body) | reset-password is called | 400 VALIDATION_ERROR error response is returned |
| email belongs to existing user, EmailSender throws | forgot-password is called | 200 OK, reset token created in DB, email_log entry with status=FAILED, no exception propagated |
Was this page helpful?
Thanks for the feedback.