= Security Fixes to Address Reported Vulnerabilities

  There were a large number of reported vulnerabilities that are fixed
  in this version. While none are as critical as the webauthn_login
  vulnerability fixed in 2.46.0, this release does fix some serious
  vulnerabilities. The vulnerabilities are separated by type:

  * Security Issues Not Requiring Race Conditions or Privileged Information
  * Security Issues Not Requiring Race Conditions
  * Race Conditions Not Requiring Privileged Information
  * Race Conditions Requiring Privileged Information

  Thank you to Joshua Rogers (https://joshua.hu) of AISLE Research
  (https://aisle.com) for privately reporting these vulnerabilities.

  Timeline (all times UTC):

  * 2026-08-22 10:25am: Email received from Joshua Rogers with 12
    security findings.
  * 2026-08-22 19:37pm: Email from Joshua Rogers opened, analysis
    started.
  * 2026-08-22 20:33pm: Response to Joshua Rogers confirming the
    findings and initial analysis.
  * 2026-08-22 22:23pm: Email received from Joshua Rogers with 14
    additional security findings.
  * 2026-08-23 00:54am: Response to Joshua Rogers confirming the
    findings and initial analysis of second report.
  * 2026-08-24 01:39am: Fixes developed and committed, work
    begins on the 2.47.0 release.
  * 2026-08-24 05:20am (approximate): Rodauth 2.47.0 released

== Security Issues Not Requiring Race Conditions or Privileged Information

* The return to path configuration methods would previously allow
  redirecting to remote URLs, most simply by making a request to
  a path that started with //. The related configuration methods
  (both of which return false by default) are:

  * login_return_to_requested_location?
  * two_factor_auth_return_to_requested_location?
  
  The confirm_password feature implicit works by returning to
  requested locations, so it was also vulnerable.

  All of these now check that the return to path starts with /
  but not //, ensuring that it should be considered a local
  path and not a remote URL.

  Additionally, only the login_return_to_requested_location? feature
  implemented checks for request method (only allow if original
  request used GET) and path size (only allow if under 2K bytes).
  These checks have been moved from the login feature to the base
  feature, and are now applied to all three cases.  Additionally,
  two configuration methods are added to configure this support:

  * return_to_path_max_size: The maximum size in bytes of a path to
    return to.
  * valid_return_to_path?(path): This determines whether the path is
    valid. By default it does the / but not // check and the path
    size check.

  This change can break applications that are designed to redirect
  to remote URLs, such as applications that operate as an
  authentication gateway for other domains. Such applications should
  use the valid_return_to_path? configuration method appropriately
  for their needs.

* The json feature previously used a content type regexp that was
  not anchored to the beginning of the content-type header. This
  allowed using application/json as a type parameter to another type
  that was allowed without a CORS preflight request. As Rodauth allows
  JSON requests without CSRF by default (thinking that they would
  be require a CORS preflight request and therefore the CSRF is
  unnecessary), this resulted in Rodauth being open to CSRF
  attacks in some cases if the json feature was enabled, the
  check_csrf was not used explicitly to require CSRF checks,
  and the json_request_content_type_regexp was not used
  explicitly with a properly anchoring regexp.

  Whether the application is actually vulnerable to CSRF depends
  on the session implementation. With Roda's sessions plugin,
  and other session implementations that use SameSite=Lax, CSRF
  attacks are limited to same-site-cross-origin attacks. For
  endpoints that don't require a logged in session, such as the
  login endpoint, the SameSite protection doesn't apply, and this
  could be used to force login into an attacker-controlled account.

  This issue is fixed by anchoring the default
  json_request_content_type_regexp with \A, preventing the ability
  to have application/json as a type parameter be considered a json
  request. Cross origin requests that Rodauth would treat as JSON
  requests now require a CORS preflight request.

* Rodauth's documentation was updated to point out potential session
  fixation problems when using session implementations based on
  Rack::Session::Abstract::Persisted. If you are using a session
  implementation based on Rack::Session::Abstract::Persisted, and
  cannot switch to another approach, you should use the clear_session
  configuration method to force a new session:

    clear_session do
      session.options[:renew] = true
      session.clear
    end

== Security Issues Not Requiring Race Conditions

* The jwt feature now removes the Authorization response header
  if the request is not a POST request or it doesn't accept
  JSON. When using the jwt_refresh feature, this fixes the
  ability to get a new access token by submitting a non-POST
  request to the jwt_refresh route.

  Additionally, the jwt_refresh feature no longer allows decoding
  expired JWTs for non-POST requests. Previously, decoding of
  expired JWTs was allowed during requests to the jwt_refresh path
  regardless of the request method used.

  Previously, this issue could be abused to continually get new
  access tokens if an attacker already had a expired access token
  but no refresh token.

* The otp feature now prevents otp code reuse during the drift
  window. Previously, it was possible to replay an otp code during
  the drift window. In order for this to be exploitable, the
  attacker must have access to an already used but otherwise valid
  OTP code. While not technically a race condition, the window in
  which this could be exploited would be limited to 30 seconds by
  default.  Note that preventing reuse during the drift window
  requires rotp 4 (released in March 2018).

* Adding a webauthn authenticator after using the webauthn login
  feature no longer marks the session as being two factor
  authenticated. In order for this to be exploitable, the
  application would need to restrict some endpoints to only sessions
  with two factor authentication. If a user wanted to exploit this,
  they could easily setup an actual separate factor, access what
  they want, and then remove the separate factor, so this bug in
  general allowed security policy violations but not actual
  security vulnerabilities.

  Related to this bug fix, attempts to update a session to being
  two factor authenticated by using an authentication type that
  was used as the original factor will now raise a Rodauth::Error.

* A jwt_refresh token created using the old hmac secret instead of
  the current hmac secret was previously not deleted from the
  database during logout, allowing it to be used until it expired
  for creating new refresh tokens. This is only exploitable if the
  attacker gains access to a refresh token created using the old
  hmac secret after it was used during logout, and only then until
  the refresh token expires.

* When using the password_pepper feature and using the
  password_maximum_bytes configuration method to set a maximum size
  for the password, the length of the password pepper was not
  included in the calculation, allowing the actual password given to
  the hash algorithm to be longer than password_maximum_bytes. When
  using the bcrypt hash algorithm with a password_maximum_bytes of
  72 (the bcyrpt limit), this could allow long bcrypt passwords to
  be set in such as way as that some or all of the pepper bytes are
  ignored. In general, a 72 byte bcrypt password is probably secure
  enough the pepper adds no significant additional security, but it
  is possible there are cases where the pepper would be needed for
  security, and the user should provide a shorter bcrypt password
  to accomodate the pepper. In order to exploit this feature (use
  a password that would be truncated by bcrypt), an attacker must
  have access to either change the password or login with the
  password.

  Note that Rodauth does not set password_maximum_bytes by default,
  but in Rodauth 3, password_maximum_bytes will default to 72 if
  bcrypt is used as the password hash algorithm.

* Non-json requests to the POST endpoints for the email token
  features will no longer consider a token submitted as a parameter.
  These features are:

  * email_auth
  * lockout
  * reset_password
  * verify_account
  * verify_login_change

  These features all have the same expected non-JSON workflow:

  * Submit GET request with token parameter (click on link in email)
  * GET request takes token parameter, validates it, adds it to
    session, redirects to same path with no parameter
  * With token in session, GET request shows form for completing
    action
  * Form submits via POST

  In these workflows, the token should never been provided as a
  parameter to the POST request. For non-json requests, the POST
  endpoints will no longer consider tokens submitted as a parameter,
  protecting them somewhat against cases where the application would
  be vulnerable to CSRF attacks.

  In order to exploit this issue, an attacker must have access to a
  current valid token, and the application must be vulnerable to CSRF.

  It's possible this change could break custom workflows. The
  allow_param_fallback_for_session_param? configuration method
  has been added, which can be set to true if you need to support
  tokens as parameters to POST endpoints in non-JSON workflows.

* When using the webauthn_login feature and a passwordless webauthn
  login, adding or removing a webauthn authenticator is no longer
  allowed if the account is setup for two factor authentication but
  the session is not two factor authenticated. Previously, you could
  add or remove a webauthn authenticator in this case, but that goes
  against Rodauth's general principle of requiring two factor
  authentication for account changes if the account is setup for two
  factor authentication. In order to exploit this, an attacker would
  need access to a valid webauthn authenticator or a valid session
  for a passwordless account that has been setup for two factor
  authentication.

== Race Conditions Not Requiring Privileged Information

* The otp and sms_codes features both allowed concurrent
  authentication requests for the same account to bypass the limit
  on allowed failures, allowing them more guesses than would otherwise
  be allowed. The features now use transactions and FOR UPDATE queries
  to enforce specified limits.

  In general, while this can increase the risk of an account
  compromise, the maximum number of concurrent requests is still
  low enough to make a blind guess approach successful. Trying
  to exploit this is also very noisy, and an unsuccesful attack
  will result in otp or sms lockout. Additionally, attackers can
  only attempt this attack after they have already partially
  compromised the acccount (such as getting single factor access
  but not multifactor access). This is only a feasible exploit
  approach if you have limited the number of potential values to
  more than the number the allowed failures but less than number
  of concurrent failing requests before triggering lockout.

== Race Conditions Requiring Privileged Information

* When setting a new password, handle cases where there was a
  concurrent password change by reporting an error, instead of
  silently succeeding. This issue is only exploitable if an
  attacker has the ability to change the password.

  set_password now raises a Rodauth::SetPasswordFailure exception
  in this case, and callers now handle this exception:

  * change_password: An error message is shown, and the user can try
    again.
  * password_pepper (during pepper upgrade) and update_password_hash
    (during hash cost/algorithm change): handled as an invalid
    password, since the password has changed.

  Note that the change password security requires that the user
  provide the existing password. If password modifications do not
  require the current password provided, than the change cannot
  be protected against.

* All features that used email tokens were subject to race conditions
  for concurrent requests using those tokens.  These features are:

  * email_auth
  * lockout
  * reset_password
  * verify_account
  * verify_login_change

  In order to be exploitable, an attacker must have access to the
  related token, and must choose to use them during a concurrent
  request by the victim.

  These issues were all addressed by expanding transaction scope and
  using FOR UPDATE on the query to retrieve the token to serialize the
  access.

  In addition to this fix, these features leaked tokens into the
  session in some cases. The features have been updated to delete
  the tokens appropriately from the session.

* The recovery_codes feature was vulnerable to concurrent use of the
  same recovery code. When removing the recovery code during the
  recovery code authentication process, Rodauth now checks that a
  single row was deleted, and returns false if that isn't the case.
  This issue was only exploitable if an attacker had access to a
  valid recovery code, and chose to authenticate with it during a
  concurrent request by the victim with the same code.

* The jwt_refresh feature was vulnerable to concurrent use of
  the refresh token. It was also fixed by expanding transaction
  scope and using FOR UPDATE. This issue was only exploitable if
  an attacker had access to the same refresh token as the victim,
  and chose to refresh the token during a concurrent request by
  the victim.

* The verify_account feature was updated to only verify the
  account if the account was still in an unverified state during
  the query to verify it. This could only be exploited if an
  attacker had a valid verify account token, and would require the
  account have a concurrent account status change. Potentially,
  the previous behavior could allow a concurrent account close
  during the grace period (with the verify_account_grace_period
  feature) to be overridden by the account verification.

= Other Security Fixes

* The single_session feature now treats a session as invalid if the
  session does not have a single session key, but a single session
  key for the account exists in the database. This indicates that
  the single_session feature was added after the session was created,
  and that there was another session created afterward (for the
  database to have a session). In this case, it appears there is
  another, more recent session, so the current session should be
  considered invalid.

* The password_grace_period now prevents extending the grace period
  by using the change password feature and attempting to change the
  password to the current password.

= Other Improvements

* Cases where Rodauth could use either the account_id or the session
  value have been consolidated to use an approach where the account_id
  is used if there was an attempt to load an account, and the
  session_value is used only if there was no attempt to load an
  account. That is safer the approach of using the session_value if
  the account_id is not available (even if there was an attempt to
  load an account), or worse, using the session_value if available
  instead of the account_id (the cause of the webauthn_login
  vulnerability fixed in 2.46.0). There were two cases where the
  session_value was used in preference to the account_id:

  * webauthn_verify_account: Unlikely webauthn_login, this doesn't
    appear to have been exploitable unless you have the verify account
    token, in which case you don't need an exploit to control the
    account.

  * active_sessions: This could potentially be used to remove, clear,
    or set active session records for an attacker controlled account.
    It does not allow an attacker to remove, clear, or set active
    session records for an account they do not control. Since users
    often have the ability to remove, clear, or set their own active
    session records, this does not appear to have been exploitable.

* Rodauth now warns or raises if you attempt to load an account
  multiple times in the same instance during a Rodauth route. Rodauth
  now records the reason the account was loaded. If an account was
  loaded multiple times for the same reason (e.g. both the from
  session value), Rodauth warns, since this is inefficient but
  unlikely to be a security issue. If an account was loaded for a
  different reason than the original load, Rodauth raises a
  Rodauth::Error, as this can result in a security issue due to
  potential account confusion.

  This found a couple of duplicate retrieval issues inside Rodauth,
  which this release fixes.

  If this account reloading prevention causes problems in your
  application, is probably better to fix the underlying issues, but
  you can use the new account_reload configuration method to allow
  account reloads by having it return nil and not call super.

* The security fix in 2.46.0 could result in require_authentication
  failing in applications using the webauthn_login feature. The
  new approach of using the account_id if an account is loaded,
  instead of basing the account_id/session_value decision on the
  current route, should fix this issue.

* The jwt_cors feature now sets the Vary response header to Origin
  so that caches will not return a response for one origin if there
  is a request for different origin.  As Rodauth's JSON API only
  supports POST requests, it is unlikely a response would be cached,
  so this is done as a best practice and not to prevent an actual
  vulnerability. A jwt_cors_vary_header configuration method has been
  added to use to configure the value of the Vary header.

* The verify_login_change feature now disallows verification for
  unverified or closed accounts. Unverified accounts should verify
  themselves before changing their login, and there is no reason
  to change the login for a closed account.

* The README received substantial updates to explain how Rodauth
  handles some security aspects, as well as recommended configuration
  methods to use to improve security.

= Backwards Compatibility

* Rodauth now warns if you are loading a feature that should be
  using a configuration method for security reasons, but the
  configuration method is not used. The following configuration
  methods have such warnings:

  * domain: By default, this uses request.host, which comes from
    Rack, and is insecure by default because it tries to determine
    the host from the request using potentially attacker-provided
    information. In any case where you will be sending emails, not
    setting domain is likely to result in a serious security issue
    in the application.

  * hmac_secret: Rodauth has significant security benefits from
    setting an hmac secret for the features that can use it but
    do not require it.

  * already_logged_in: For backwards compatibility, Rodauth allows
    already authenticated sessions to access endpoints that are 
    designed to be used by unauthentication sessions. This hightens
    the risk of account confusion attacks. It's strongly recommended
    that this configuration method be used to halt or redirect, in
    order to prevent potential account confusion attacks (doing so
    prevented the webauthn_login vulnerability fixed in 2.46.0).

  If you are not loading a feature that uses the configuration
  method, Rodauth will not emit a warning for the method. If you
  want to avoid a particular warning and use the default value,
  you can call the configuration method with a block containing
  super(). Alternatively, to change the behavior for all of these
  configuration warnings, you can use the new
  missing_recommended_configuration(msg) configuration method.

  In Rodauth 3, these missing recommended configuration warnings
  will change to raised exceptions by default.

* External features that set the @account instance variable should
  switch to using the new set_account internal method. If the
  feature is designed to reload accounts, it can set
  @account_retrieval_type to nil before calling set_account.
