Email-code signup asks for a username before the account exists
A correct code no longer creates a new member's account by itself. The server answers with a details step, and the account is created by the request that brings the chosen username back.
The account is created by the request that carries the username
For a new address on an open site, a correct code now returns username_required with a
suggested name. The form shows a details step for username and avatar, and a second verify request
carrying the chosen name creates the account. CAPTCHA, username validation and consuming the code all
move to that second request. An avatar picked on the step is saved by the browser once the account
exists. A plugin can also switch the step to a server-generated name that the form submits by itself.
The description says this restores a reverted change with a distinct details state.
Two smaller changes ride along:
- A caller checking a username for another user (
for_user_id) must now be signed in and allowed to edit that user's username. enable_random_usernamesnow defaults to off.
- Before
- Entering the code created the account with a generated name, which the new member could then rename.
- After
- The new member chooses the name, and optionally an avatar, before the account is created.
What the judgments below rely on
- email entered
- request codeapp/controllers/session_controller.rb:516code sent
- submit codeapp/controllers/session_controller.rb:975account created
- renamefrontend/discourse/app/components/code-login-form.gjs:751username changed
- email entered
- request codeapp/controllers/session_controller.rb:519code sent
- submit codeapp/controllers/session_controller.rb:1021-1027username asked
- submit code and usernameapp/controllers/session_controller.rb:978-996account created
- Existing users: one request, which logs them inapp/services/email_login_code/redeem.rb:136-138
- Sites requiring user fields or a full name: one more request before the username stepapp/controllers/session_controller.rb:970-976
- Approval sites: details first, then a separate completion request creates the accountapp/controllers/session_controller.rb:1077-1101
- Email-code invites: the same username step, answered by the invite serviceapp/controllers/session_controller.rb:1267-1273
- The CAPTCHA plugin's controller patch
- The bundled
discourse-captchaplugin prepends a module ontoSessionControllerthat wrapsprocess_verified_login_codeand decides, per request, whether a CAPTCHA token is required. plugins/discourse-captcha/plugin.rb:28 Used by Checkpoint A - Approval signup
- With
must_approve_userson and an address that is not auto-approved, a verified code returns a signup token held in the server session rather than an account. app/controllers/session_controller.rb:1049-1075 Used by Checkpoint A - Plugin modifiers
DiscoursePluginRegistry.apply_modifierreturns the default value it is handed unless some plugin has registered a modifier under that name. lib/discourse_plugin_registry.rb:306-310 Used by Checkpoint C
What needs your attention
The first four turn on the new request sequence, the last two on the bundled changes. The order is the order to think about them.
Does every request that creates an email-code account still pass CAPTCHA?
The patch used to demand a token on the request that submitted the code. It now demands one only
when the request carries a username, is not an approval signup, and has no user fields or full name
missing. Those negations mirror the controller's early returns, so the requests it lets through
stop at required_username_provided before the code is consumed. Approval signups are
checked on the completion request instead. Decide whether that mirroring holds for every way a
request reaches EmailLoginCode::Redeem.
-
The new predicate
the username condition, and the existing-user and invite exits above it
▸ plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:39-46 Changed
return false if matched_user.present? || params[:invite_key].present? return false if !registration_via_login_code_open? - pending_approval_signup?(matched_user) || - (!signup_user_fields_missing? && !signup_full_name_missing?) + !pending_approval_signup?(matched_user) && params[:username].present? && + !signup_user_fields_missing? && !signup_full_name_missing? end include CaptchaVerification
-
The exits it mirrors
approval signups, then missing fields or name, both returning before Redeem
▸ app/controllers/session_controller.rb:966-976 At head
return process_verified_invite_login_code(matched_user) if params[:invite_key].present? return begin_verified_login_code_signup if pending_approval_signup?(matched_user) if matched_user.nil? && registration_via_login_code_open? user_fields_required = signup_user_fields_missing? name_required = signup_full_name_missing? if user_fields_required || name_required return render json: { user_fields_required:, name_required: } end end
-
Approval completion check
runs CAPTCHA when the stored proof was not verified, which is now every approval signup
▸ plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:17-25 At head
def complete_verified_login_code_signup proof = verified_login_code_signup_proof_for(params[:signup_token].to_s) if proof.is_a?(Hash) && !proof[:captcha_verified] && (error = captcha_verification_error) render json: { error: I18n.t(error) } return end super end
- What the request specs pin no CAPTCHA on the code-only request, no account without one, and the generated-name case plugins/discourse-captcha/spec/requests/discourse_captcha/session_controller_spec.rb:16-75
When a chosen username is rejected, is the code still good for another try?
Every new account now arrives with a member-chosen name, so rejection is the ordinary path. On the
login-code path the reserved-name policy runs before the transaction, and a format or uniqueness
failure fails the model step inside it, which leaves the code unconsumed
inferred from tests. On the invite path
InviteRedeemer now takes a verified-email username as given instead of falling back to
a suggestion, so the User validator at
app/models/user.rb:2156-2181 is its only
format and uniqueness check, and the
resulting RecordInvalid comes back as username_errors
from unchanged code. Judge whether both paths leave the code and
any staged account reusable.
-
Policies, then the transaction
username_allowedruns beforeconsume_codeand account creation▸ app/services/email_login_code/redeem.rb:46-56 At head
policy :username_allowed policy :required_fields_provided policy :required_full_name_provided policy :required_username_provided lock(:email) do transaction do step :consume_code model :user, :ensure_user end end
-
Invite username taken as given
the format and availability branch is skipped once the email is verified
▸ app/models/invite_redeemer.rb:99-108 Changed
) raise Discourse::SiteArchived if SiteSetting.site_archived - if username && UsernameValidator.new(username).valid_format? && - User.username_available?(username, email) + if email_verified && username.present? + available_username = username + elsif username && UsernameValidator.new(username).valid_format? && + User.username_available?(username, email) available_username = username elsif email_verified available_username =
-
The validator that now decides
format and uniqueness always; reserved names only with
enforce_username_restrictions, which the invite path never sets, so the invite service's own policy covers them from unchanged code▸ app/models/user.rb:2156-2174 Unchanged
def username_validator username_format_validator || begin if will_save_change_to_username? if enforce_username_restrictions && ( User.reserved_username?(username) || UsernameValidator.clashing_with_existing_route?(username) ) errors.add(:username, I18n.t("login.reserved_username")) end existing = DB.query(USERNAME_EXISTS_SQL, username: self.class.normalize_username(username)) user_id = existing.select { |u| u.is_user }.first&.id same_user = user_id && user_id == id errors.add(:username, I18n.t(:"user.username.unique")) if existing.present? && !same_user
- Rejection specs on the invite service missing, reserved, route-clashing, invalid and taken names each assert the code unconsumed; the login-code service carries the same contexts at spec/services/email_login_code/redeem_spec.rb:220-290 spec/services/invite/redeem_with_email_code_spec.rb:171-250
What should the server-side generated-username flag change?
The controller sets generated_username only when a proof in the server session matches
the code, the email, the name and the signup context, and Redeem forwards it to
CreateFromVerifiedEmail, whose call never reads it. Its whole server-side
effect is in the controller: a rejected generated name is replaced with a fresh one instead of
returning an error. The mode is only on when a plugin registers
email_login_code_username_mode; otherwise
lib/discourse_plugin_registry.rb:306-310
returns the default, and no plugin in this repository registers it
from unchanged code. The same is true one layer up: the contract still declares
username_required at
app/services/email_login_code/redeem.rb:11
and the approval completion still sends it at
app/controllers/session_controller.rb:1095,
but no policy reads it any more. Decide whether each unused input is leftover plumbing or a missing
behaviour.
-
The option nothing reads
declared beside
username; restrictions are still decided byusername.present?app/services/user/action/create_from_verified_email.rb:3-39 -
Where Redeem forwards it
true only when the proof's code id matches the code being redeemed
▸ app/services/email_login_code/redeem.rb:146-158 At head
def ensure_user(existing_user:, login_code:, params:, ip_address:, options:) generated_username = options.generated_username && options.generated_username_login_code_id == login_code.id existing_user || User::Action::CreateFromVerifiedEmail.call( email: params.email, ip_address: ip_address, user_fields: params.user_fields, name: params.name, password: params.password, username: params.username, generated_username: generated_username, )
-
The mode switch
defaults to
:requiredunless a plugin modifier answers otherwise▸ app/controllers/session_controller.rb:1347-1356 At head
def email_login_code_username_mode(email, signup_context) DiscoursePluginRegistry.apply_modifier( :email_login_code_username_mode, :required, email, server_session, signup_context, cookies[:destination_url], ) end
- A retry instead of an error a rejected generated name re-renders the details step with a new one app/controllers/session_controller.rb:1004-1014
Open question Is generated_username on
CreateFromVerifiedEmail meant to relax a check, and should the unread
username_required go?
Is it acceptable that a chosen avatar can be lost without a word?
The avatar is picked before the account exists and saved by the browser after
account_created: an upload then pickAvatar, or selectAvatar
for a preset, with every failure swallowed before the redirect. The picker offers upload when
can_upload_avatar is true, which only checks groups, but the upload endpoint also
refuses avatars when auth_overrides_avatar or
discourse_connect_overrides_avatar is set
(app/controllers/uploads_controller.rb:51-56)
from unchanged code. On such a site a member can choose a file,
see it previewed, and arrive with the default avatar. Approval signups never reach this save, because
app/controllers/session_controller.rb:1180-1181
answers pending_approval first.
-
The deferred save
upload or select as the new user, then an empty
catch▸ frontend/discourse/app/components/code-login-form.gjs:1018-1040 At head
await updateCsrfToken(); if (selection.file && result.can_upload_avatar) { const data = new FormData(); data.append("type", "avatar"); data.append("user_id", user.id); data.append("files[]", selection.file); const upload = await ajax("/uploads.json", { type: "POST", data, processData: false, contentType: false, timeout: 30000, ignoreUnsent: false, }); if (upload?.id) { await user.pickAvatar(upload.id, "custom"); } } else if (selection.url) { await user.selectAvatar(selection.url); } } catch { // Avatar customization must not block an account that was successfully created. }
-
The upload endpoint's refusal
the two override settings and the group check, returning 422 for an avatar upload
from unchanged code
▸ app/controllers/uploads_controller.rb:51-56 Unchanged
if type == "avatar" && ( SiteSetting.discourse_connect_overrides_avatar || SiteSetting.auth_overrides_avatar || !me.in_any_groups?(SiteSetting.uploaded_avatars_allowed_groups_map) ) return render json: failed_json, status: :unprocessable_entity
-
What the picker is told
can_upload_avatarfrom the default trust-level groups and any invite groups app/controllers/session_controller.rb:1318-1333
Open question Should the override settings hide the upload option, or should a failed save tell the member?
Should sites that never set enable_random_usernames lose random names?
The setting arrived on 2026-08-27 defaulting to on, and this flips the default. Every site still on
the default stops getting adjective-and-noun suggestions on the username and approval details steps,
loses the regenerate button
(frontend/discourse/app/components/code-login-form.gjs:1282),
and gets a 404 from the random-username endpoint. The plugin-driven
generated mode ignores the setting through allow_when_disabled: true. Whether a release
shipped with the old default is uncertain; the release tags would settle
it.
-
The default
true to false, still dependent on code logins
▸ config/site_settings.yml:696-702 Changed
- everyone enable_random_usernames: client: true - default: true + default: false area: "login" depends_on: - enable_local_logins_via_code
-
The endpoint guard
ensure_random_username_accessraises NotFound when the setting is off▸ app/controllers/users_controller.rb:2193-2197 At head
def ensure_random_username_access raise Discourse::NotFound if !SiteSetting.enable_random_usernames return redirect_to_login_if_required if current_user return if !SiteSetting.login_required?
- The bypass generated names call the generator whatever the setting says app/controllers/session_controller.rb:1358-1370
Does the new for_user_id guard turn away a caller that still needs it?
check_username used to load any for_user_id for any caller, anonymous
included, and check availability against that user's email. It now raises for an anonymous caller
and requires ensure_can_edit_username!; the signup form stopped sending the id. The
username preference, which does send it, only shows its edit control when the serialized
can_edit_username is true, computed by the same guardian method at
app/serializers/user_serializer.rb:216-218
from unchanged code. Decide whether any caller outside this
repository relied on the old behaviour.
-
The guard
anonymous callers refused, then the edit permission enforced
▸ app/controllers/users_controller.rb:714-721 At head
def user_from_params_or_current_user return current_user if !params[:for_user_id] raise Discourse::InvalidAccess if !current_user user = User.find(params[:for_user_id]) guardian.ensure_can_edit_username!(user) user end
-
The guardian rule
overrides, staff, the change period, then self within it
from unchanged code
▸ lib/guardian/user_guardian.rb:24-30 Unchanged
def can_edit_username?(user) return false if SiteSetting.auth_overrides_username? return true if is_staff? return false if SiteSetting.username_change_period <= 0 return false if is_anonymous? is_me?(user) && user.created_at > SiteSetting.username_change_period.days.ago end
-
Where the preference gets its flag
the same guardian call, so its edit control and the guard agree
from unchanged code
▸ app/serializers/user_serializer.rb:216-218 Unchanged
def can_edit_username scope.can_edit_username?(object) end
Read the code in this order
- 01 app/services/email_login_code/redeem.rb:46-56 The policy order that stops a request without a username before the code is consumed; everything else reacts to it. Checkpoint B →
- 02 app/controllers/session_controller.rb:966-1044 The verify action: early exits, then Redeem and the handlers that turn a missing or rejected name into the details step. Checkpoint A →
- 03 plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:38-44 The CAPTCHA predicate, read against the exits in the previous stop. Checkpoint A →
- 04 app/controllers/session_controller.rb:1299-1406 The username-required response, the generated-name proof, and the clean-up after login; also where the avatar permission is computed for Checkpoint D. Checkpoint C →
- 05 frontend/discourse/app/components/code-login-form.gjs:387-547 The client side of the sequence, ending in the deferred avatar save. Checkpoint D →
- 06 config/site_settings.yml:697-702 The default flip, separate from the sequence. Checkpoint E →
- 07 app/controllers/users_controller.rb:714-721 The availability check's new permission guard. Checkpoint F →
Impact outside the diff
On sites running the CAPTCHA plugin the challenge now appears beside the username field rather than under the code entry from unchanged code. Checkpoint A
- code-login-after-codeoutlet moved to details stepsfrontend/discourse/app/components/code-login-form.gjs:1498-1501
- rendered byCaptchaFieldsConnectorhCaptcha or reCAPTCHAplugins/discourse-captcha/assets/javascripts/discourse/connectors/code-login-after-code/captcha-fields-connector.gjs:11
- produceschallenge beside the username fieldobservable behaviour
A malformed or taken name chosen by an email-code invitee is no longer swapped for a suggestion; it is rejected and shown as a username error from unchanged code. Checkpoint B
- InviteRedeemer.create_user_from_invitekeeps the given nameapp/models/invite_redeemer.rb:102-106
- passesUser#username_validatorformat, uniquenessapp/models/user.rb:2156-2181
- producesusername error on the details stepobservable behaviour
A name-based pass cannot see plugins outside this repository that
register email_login_code_username_mode, listen for
email_login_code_authenticated, or render into code-login-after-code.
Affected, not changed
- plugins/discourse-captcha/assets/javascripts/discourse/connectors/code-login-after-code/captcha-fields-connector.gjs:11 — renders wherever the moved outlet now sits. Checkpoint A
- app/models/user.rb:2156-2181 — now the only format and uniqueness check on an email-code invitee's name. Checkpoint B
- app/controllers/uploads_controller.rb:51-56 — now receives avatar uploads the signup picker offered, and may refuse them. Checkpoint D
▸ Evidence & diff coverage · all 36 changed paths
▸ Searches run, and what they could not reach
git grep -n "code-login-after-code\|registerBehaviorTransformer\|registerValueTransformer" plugins/discourse-captchaone outlet connector, one behaviour and one value transformergit grep -n "email_login_code_username_mode\|email_login_code_authenticated" -- ':!spec'onlysession_controller.rb; the CAPTCHA request spec registers the modifiergit grep -n "generated_username" app/services/userdeclared atcreate_from_verified_email.rb:6, never readgit grep -n "email_verified" -- app lib pluginsonlyInvite::RedeemWithEmailCodepasses truegrep -rn "enforce_username_restrictions" app lib pluginsset only increate_from_verified_email.rb:32git grep -n "checkUsername(\|for_user_id" -- frontend plugins appusername-preference.gjsis the only other caller sending an idgit grep -n "RandomUsernameGenerator\|enable_random_usernames"the generator, the endpoint guard, three client reads, three controller calls, the invite fallbackgrep -n "changeAvatar\|pendingAvatar" frontend/discourse/app/components/code-login-form.gjsavatar only on the signup-details step
Out of reach. Plugins and themes outside this repository that
register the new modifier, listen for the new event, render into the moved outlet, or call
check_username with for_user_id.