Backend user management for Neos 8 and 9: forgot password, initial password via mail, email validation, a replacement for the core user module, second factors (TOTP, passkeys) and passwordless passkey login.
Successor of sandstorm/neostwofactorauthentication (abandoned) - upgrading? See MIGRATION.md.
composer require sandstorm/backendusermanagement
./flow doctrine:migrate
All settings with their defaults: Settings.UserManagement.yaml,
Settings.2FA.yaml.
Features
On by default: forgot password and the CLI commands. Optional: the user management module
and username follows the email. Mails are sent via
neos/symfonymailer - configure Neos.SymfonyMailer.mailer.dsn and:
Sandstorm:
BackendUserManagement:
userManagement:
# only accounts of these providers are managed
authenticationProviders: ['Neos.Neos:Backend']
mail:
from:
address: 'no-reply@example.com' # required - without it, no mail is sent (error in the security log)
name: 'My CMS'
Neos:
Flow:
http:
# required for the CLI commands (links in mails)
baseUri: 'https://cms.example.com/'To turn it off: Sandstorm.BackendUserManagement.userManagement.enabled: false.
Add a daily cron job for ./flow usermanagement:prunetokens.
Links in mails are single-use, stored as SHA-256 hashes, expire, are invalidated by a newer link of the same kind, and are only consumed on POST (mail scanners prefetch). Setting a password logs out all sessions of the user and never logs in, so 2FA stays in force.
Sandstorm:
BackendUserManagement:
userManagement:
# ISO 8601 durations
tokenLifetime:
resetPassword: 'PT24H'
initialPassword: 'P7D'
verifyEmail: 'P7D'
passwordPolicy:
minimumLength: 1
maximumLength: 255"Forgot your password?" on the login screen. Users enter username or email and get a link to set a new password. The response is identical whether an account exists or not; several accounts sharing an email get one mail.
Sandstorm:
BackendUserManagement:
userManagement:
# only send to validated primary email addresses
requireValidatedEmail: trueUsers created before (e.g. via the core user module) usually have no or no validated email address:
- They get no mail. The response is still the generic one; only the security log has a warning. Validate their
addresses with
./flow usermanagement:setemail <username> <email> --validatedor, in the user management module, send them a confirmation link or "Mark as validated" (its index shows how many users are affected). - Shared addresses: all accounts sharing an email get their links in one mail to it. Find them with
./flow usermanagement:duplicateemailsand give each user its own address. requireValidatedEmail: falsecovers them without validating, but sends links to addresses nobody ever confirmed (possibly stale or mistyped) - not recommended.
Forgot-password mails go through a Flowpack.JobQueue.Common queue so
the response time does not leak whether an account exists. Default: async FakeQueue (one background ./flow
process per mail, needs exec() and Neos.Flow.core.phpBinaryPathAndFilename). For a real queue:
Flowpack:
JobQueue:
Common:
queues:
'Sandstorm.BackendUserManagement:UserManagementMail':
className: 'Flowpack\JobQueue\Redis\Queue\RedisQueue' # then run ./flow job:workNever set async: false - the request would wait for the mail again.
Requests are rate-limited per username/email and per IP (symfony/rate-limiter, sliding window):
Sandstorm:
BackendUserManagement:
userManagement:
throttle:
perIdentifier:
limit: 3
window: 'PT15M'
perIp:
enabled: true
limit: 50
window: 'PT15M'
# symfony/lock DSN
lockStore: 'flock'Behind a reverse proxy, trust it - otherwise all clients share the proxy's IP and the per-IP limit becomes
global. Don't use '*', clients could then spoof X-Forwarded-For:
Neos:
Flow:
http:
trustedProxies:
proxies: # or env FLOW_HTTP_TRUSTED_PROXIES
- '10.0.0.0/8'If you can't (or a WAF already limits requests), disable the per-IP limit instead with throttle.perIp.enabled: false.
Several Neos containers: limiter state and lock default to the local file system, so each container counts on
its own. Share both, e.g. via Redis (needs the PHP redis extension; redis:// lock store needs symfony/cache):
# Configuration/Caches.yaml
Sandstorm_BackendUserManagement_Throttle:
backend: Neos\Cache\Backend\RedisBackend
backendOptions:
hostname: 'redis'
port: 6379
database: 2# Configuration/Settings.yaml
Sandstorm:
BackendUserManagement:
userManagement:
throttle:
lockStore: 'redis://redis:6379'Administrators create users without a password; the user gets a link to set one (lifetime:
tokenLifetime.initialPassword). Setting a password via a link also validates the email address.
The user's primary email (Neos.Party ElectronicAddress, approved = validated). Changing it resets validation and
sends a confirmation mail - unless the admin marks it as validated in the edit form (lifetime: tokenLifetime.verifyEmail); admins can also mark it as validated.
Sandstorm:
BackendUserManagement:
userManagement:
usernameIsEmail: true # default: falseNew accounts are named after the email (username field hidden). On email change, accounts named after the old address are renamed once the new one is validated; the user is logged out everywhere and gets a mail with the new username. If the name is taken, the rename is skipped (flash message, security log). Accounts whose username is not the old address (e.g. created before the setting was enabled, or users without an email) are never renamed. On Neos 8 the personal workspace is migrated with its unpublished changes.
To rename such accounts to the (validated) email explicitly: "Use email address as username" in the module (not for
yourself), or ./flow usermanagement:useemailasusername [--username <username>] [--dry-run] for all users at once.
Same effects as above: logged out, mail with the new username, workspace migrated; taken names are skipped.
Administration > User Management, replacing the core user module with a simplified UI: search, bulk delete, create, edit (name, email,
roles per account, active, password), send reset / initial password / confirmation mails, reset second factors,
impersonate, send a test mail to yourself.
Neos:
Neos:
modules:
administration:
submodules:
users:
enabled: false # core module
userManagement:
enabled: truePermissions mirror the core module: Neos.Neos:Administrator manages everybody, Neos.Neos:UserManager only users
with the same or a subset of their roles. Nobody can delete, deactivate or lock out themselves.
The mail commands require userManagement.enabled and Neos.Flow.http.baseUri. Commands taking a <username> also
accept --authentication-provider (default: first of authenticationProviders).
| Command | |
|---|---|
usermanagement:sendinitialpassword <username> |
mail a "set your password" link |
usermanagement:sendreset <username> |
mail a "reset your password" link |
usermanagement:setemail <username> <email> [--validated] |
set the primary email (e.g. legacy users) |
usermanagement:duplicateemails |
list users sharing a primary email (legacy data) |
usermanagement:useemailasusername [--dry-run] |
rename accounts to the validated email |
usermanagement:prunetokens |
delete used/expired links (cron) |
Users register any number of second factors in Management > Two-Factor Authentication and pick one at login.
Administrators can delete factors of other users (e.g. after a lockout).
Tested with Google Authenticator, Microsoft Authenticator, Authy and 1Password.
Sandstorm:
BackendUserManagement:
secondFactor:
# label shown in the authenticator app; empty = site name
issuerName: ''
# accepted clock drift in seconds (codes from now-leeway to now+leeway); 0 = exact; values >= 30 are clamped to 29
totpLeewayInSeconds: 0Platform authenticators (Touch ID, Windows Hello) and security keys (e.g. YubiKey). Requires HTTPS; an IP address cannot be the relying-party id.
Sandstorm:
BackendUserManagement:
secondFactor:
webAuthn:
# shown in the browser prompt
relyingPartyName: 'Neos Backend'
# null = request hostname; set the registrable domain ('example.com') to share credentials across subdomains
relyingPartyId: null
# 'discouraged' | 'preferred' | 'required' - applies to passkeys as 2nd factor only.
# 'required' excludes U2F-only keys (e.g. YubiKey 4).
userVerification: 'discouraged'
# browser ceremony timeout in ms
timeout: 60000| Authenticator | 2nd factor, discouraged |
2nd factor, required |
Passwordless |
|---|---|---|---|
| YubiKey 5 / FIDO2 keys | ✅ touch | ✅ PIN + touch | ✅ |
| YubiKey 4 / U2F-only keys | ✅ touch | ❌ | ❌ |
| Touch ID, Windows Hello | ✅ | ✅ | ✅ |
Attestation is always none (formats none and fido-u2f are accepted); there is no setting for it.
webauthn-lib rejects every http:// host - including localhost - with Invalid scheme. HTTPS required. unless
listed here. Development context only, never in production:
# Configuration/Development/Settings.yaml
Sandstorm:
BackendUserManagement:
secondFactor:
webAuthn:
securedRelyingPartyIds:
- 'localhost'
- 'myproject.localhost'Users without a second factor have to set one up before they can log in.
Sandstorm:
BackendUserManagement:
secondFactor:
# for all users
enforceForAll: true
# or only for these authentication providers ...
enforceForAuthenticationProviders: ['Neos.Neos:Backend']
# ... and/or roles
enforceForRoles: ['Neos.Neos:Administrator']A "Sign in with a passkey" button on the login screen - no username, no password. A user-verified passkey counts as both factors. Demo video
Sandstorm:
BackendUserManagement:
secondFactor:
webAuthn:
passwordlessLoginEnabled: true # default: false, enforced server-side- Only discoverable credentials with user verification work. Credentials registered while this was off, and U2F-only keys, stay 2nd-factor only (badge "Passkey as 2nd factor" vs. "Passkey" in the module).
- While enabled, new registrations can be a passkey or a touch-only security key as 2nd factor.
- Users without a passkey see a "Register a passkey" banner in the module.
When the backend session expires, the Neos UI reloads the page instead of showing the core relogin dialog (which cannot handle the second factor). No configuration.
- The 2FA login screen is a copy of the
Neos.Neoslogin screen. - The passkey button is injected into the core login screen via
Configuration/Views.yamland a@processonNeos.Neos:Component.Login.Form.
E2E tests (Playwright + playwright-bdd, Neos 8 and 9 in Docker), run from Tests/E2E/:
make setup # once
make test # all variants; `make help` lists the restDetails: Tests/E2E/README.md.



