Skip to content

Public Self-Registration

Public self-registration lets anonymous visitors create their own user accounts without an admin invite. The feature is disabled by default and gated by system settings. It ships with an anti-enumeration design, optional email verification, and an allow-list filter on email addresses.

Source: src/routes/users.ts, src/services/users.ts (registerUser, verifyRegistration, getRegistrationOptions), src/services/registration-fields.ts, src/utils/match-filter.ts, migrations 016-create-settings.ts / 024-seed-defaults.ts / 029-seed-notify-templates.ts / 065-registration-fields.ts.

Overview

ConcernBehaviour
Enabled bypublic_registration setting (default false)
New user rolepublic_registration_role setting (UUID, nullable)
Email verificationpublic_registration_verify_email setting (default true)
Email allow-listpublic_registration_email_filter setting (ODP filter JSON, nullable)
Form fieldspublic_registration_fields setting (JSON list of user-collection fields, nullable → first_name + last_name)
New user statusunverified if verification required, else active
Anti-enumerationAll outcomes return 204 after a fixed stall time

Three public endpoints (no auth):

  • GET /users/register/options — registration form schema (enabled flag + fields to render)
  • POST /users/register — submit a registration
  • POST /users/register/verify-email — activate the account from the emailed token

GET /users/register/options

Public, no auth. Returns the registration form schema so a client can render the form without hardcoding fields. Safe to expose: it only reveals display metadata for the whitelisted fields, never protected columns.

Response 200

json
{
  "data": {
    "enabled": true,
    "verify_email": true,
    "fields": [
      {
        "field": "first_name",
        "type": "string",
        "interface": "input",
        "options": null,
        "required": false,
        "note": null,
        "translations": null,
        "validation": null
      }
    ]
  }
}
FieldDescription
enabledMirrors public_registration. When false, fields is []
verify_emailMirrors public_registration_verify_email
fieldsResolved, render-ready descriptors for each configured field (in order)

The fields array is produced by resolveRegistrationFields() (src/services/registration-fields.ts): each configured field is matched against the live users-collection schema and dropped if it is unknown, a base field (email/password), or protected.


POST /users/register

Public. Rate limited (rateLimiterEmail: 5 requests / 60s per IP, shared with invite and password-reset).

Request Body

json
{
  "email": "jane@company.com",
  "password": "S3cure-passw0rd",
  "first_name": "Jane",
  "last_name": "Doe",
  "phone": "+84 90 000 0000",
  "verification_url": "https://app.example.com/verify"
}
FieldRequiredDescription
emailYesValid email. Normalized to lowercase before lookup/storage
passwordYesMust pass the configured password policy
configured fieldsdependsAny field listed in public_registration_fields (e.g. first_name, last_name, phone). See Configurable fields
verification_urlNoBase URL the verification link points to. Must be allow-listed via USER_REGISTER_URL_ALLOW_LIST, else 400. Falls back to PUBLIC_URL

Server-side whitelist. The body may carry arbitrary keys, but registerUser keeps only the fields resolved from public_registration_fields. Everything else — including role, status, token, tfa_secret and other protected columns — is dropped. role and status are always forced from settings, never from the request.

Response 204 No Content — returned for every non-validation outcome (see Anti-enumeration):

  • account created and verification email sent
  • email already exists but is unverified → verification email re-sent
  • email already exists and is active → no-op
  • email rejected by the filter
  • public_registration disabled

Error responses

StatusWhen
400Missing email/password, password fails policy, or verification_url not allow-listed
429Rate limit exceeded (Retry-After header set, emits RATE_LIMIT_TRIGGERED security event)

Note: "registration disabled" and "email filtered out" do not surface as errors — they return 204 after the stall, by design.

Processing flow (UsersService.registerUser)

  1. Start the anti-enumeration timer.
  2. Validate verification_url against USER_REGISTER_URL_ALLOW_LIST (if provided).
  3. Validate and normalize the email.
  4. Load settings: public_registration, public_registration_verify_email, public_registration_role, public_registration_email_filter. If disabled → stall + return.
  5. Validate the password against policy (before any DB write, so the error is not enumeration-sensitive).
  6. Resolve public_registration_fields against the schema and whitelist the body to those fields. Enforce required fields and run per-field metadata validation (validatePayload) — these throw before any DB write and are not enumeration-sensitive.
  7. Apply the email filter via matchesFilter(). No match → stall + ForbiddenError.
  8. Case-insensitive lookup of existing user:
    • status active/other → stall + silent return (no mutation)
    • status unverified → reuse the user, re-send verification
  9. Create the user (system context, no permission gating): the whitelisted fields plus hashed password (argon2), role = public_registration_role, status = unverified (if verification on) or active. role/status always override any same-named submitted value.
  10. If verification is on, mint an email-verification JWT and send the email_verification notification template.
  11. Stall to the minimum time before responding.

POST /users/register/verify-email

Public. Not rate limited. Activates an unverified account.

Request Body

json
{ "token": "eyJhbGciOiJIUzI1NiJ9..." }
FieldRequiredDescription
tokenYesJWT with scope: "email-verification" from the verification email

Response 200

json
{ "data": { "id": "8f2c…user-uuid" } }

Error responses

StatusWhen
400Token invalid/expired, already used, or the user is no longer unverified
403Token scope is not email-verification

Verification token

  • Minted with createAccessToken(), scope: "email-verification".
  • TTL from EMAIL_VERIFICATION_TOKEN_TTL (default 7d).
  • Payload: { sub: <user-uuid>, scope: "email-verification", iat, exp }.

On success the user's status moves unverified → active.


Settings

Stored on the odp_settings singleton (migration 016-create-settings.ts). Read/write via PATCH /system-settings (admin only — see Public Registration (Admin Guide) in the App docs for the UI).

SettingTypeDefaultDescription
public_registrationbooleanfalseMaster on/off switch
public_registration_verify_emailbooleantrueRequire email verification before login
public_registration_roleuuid → odp_roles.idnullRole granted to new accounts
public_registration_email_filtertext (ODP filter JSON)nullAllow-list rule applied to the email; null = allow any
public_registration_fieldstext (JSON list)nullCurated user-collection fields exposed on the form; null = first_name + last_name (both optional)

Configurable registration fields

public_registration_fields stores a JSON list of user-collection fields to expose on the public form, beyond the always-present email + password:

jsonc
[
  { "field": "first_name", "required": true },
  { "field": "last_name" },
  { "field": "phone", "required": true }
]
  • field — a column on the users collection (odp_users). Add the field to the collection first (Settings → Data Model) so it has a real column and field metadata.
  • required — overrides the field's own required metadata for registration. Omitted → falls back to the field metadata.

Resolution + enforcement is centralised in resolveRegistrationFields() (src/services/registration-fields.ts) and shared by GET /users/register/options and registerUser:

  • Unknown fields (not on the users schema) are dropped.
  • Base fields (email, password) are handled separately and ignored here.
  • Protected fields are dropped unconditionally — even if an admin lists them — because they are security-sensitive or system-managed: id, role, status, password, password_changed_at, token, tfa_secret, last_access, last_page, provider, external_identifier, auth_data, email_notifications, appearance, theme_light, theme_dark, theme_light_overrides, theme_dark_overrides.

Because the public create path bypasses permission-based field/payload validation, registerUser re-runs required and per-field metadata validation against this resolved list itself.

Email filter format

public_registration_email_filter stores a standard ODP filter evaluated against { email } by matchesFilter() (src/utils/match-filter.ts). Supported operators: _eq, _neq, _in, _nin, _contains, _icontains, _starts_with, _ends_with, _empty, _nempty, _and, _or.

jsonc
// Single domain
{ "email": { "_ends_with": "@company.com" } }

// Multiple domains
{ "_or": [
  { "email": { "_ends_with": "@company.com" } },
  { "email": { "_ends_with": "@partner.org" } }
] }

The admin UI exposes this as a friendly comma-separated "Allowed Email Domains" field and converts to/from the filter JSON. Filters set directly via API that are not simple domain rules are preserved (the UI leaves them untouched unless the domains field is edited).


Login gating

unverified (and invited) users cannot log in. LocalAuthDriver throws UserNotVerifiedError (401, code USER_NOT_VERIFIED) until the account is activated. The unverified status is defined in USER_STATUSES (src/utils/constants.ts).


Anti-enumeration

POST /users/register is designed so that an attacker cannot tell whether an email is already registered, filtered, or whether registration is even enabled:

  • Every outcome (success, existing account, filtered, disabled) returns the same 204.
  • Each code path is padded to a minimum duration (REGISTER_STALL_TIME, default 750ms) so response timing does not leak state.
  • Password-policy validation runs before any DB mutation, so a weak-password 400 is returned uniformly regardless of whether the email exists.

Environment variables

VariableDefaultDescription
REGISTER_STALL_TIME750 (ms)Minimum duration every registration code path is padded to
USER_REGISTER_URL_ALLOW_LIST''Comma-separated base URLs allowed for verification_url. Empty = only PUBLIC_URL
EMAIL_VERIFICATION_TOKEN_TTL7dVerification JWT lifetime
PUBLIC_URLhttp://localhost:6688Fallback base URL for the verification link

Email verification delivery

When public_registration_verify_email is on, registerUser (src/services/users.ts) sends the verification email through the Notify module — it does not call MailService directly.

1. URL building (users.ts):

ts
const base = input.verification_url || env.PUBLIC_URL + '/verify-email';
const url  = base + (base.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);

The default link is PUBLIC_URL + /verify-email?token=<jwt>. A custom verification_url is only used if it passed the USER_REGISTER_URL_ALLOW_LIST check. The front-end page at that URL reads ?token= and calls POST /users/register/verify-email.

2. DispatchsendNotification({ template_id: 'email_verification', recipients: [email], variables: { url, token } }) (src/modules/notify/send.ts) runs NotifyEngine.trigger: it looks up the email_verification template, renders / , dispatches over the template's channels, and writes a delivery log.

3. Email channelsrc/modules/notify/providers/email.ts uses nodemailer, configured from Notify settings (odp_notify_settings: email_transport = smtp | ses | sendmail, plus smtp_host/port/credentials or ses_region). These are managed in the admin console (Notifications → Settings), not via env vars.

Email failure is swallowed — registration still returns `204`

The send call is wrapped in try/catch (users.ts): if the provider is unconfigured or fails, it only logs a warning and the user is still created (as unverified). So POST /users/register returns 204 even when no email is delivered. If users never receive the link, check: (1) the email provider is configured and verified in Notify settings, (2) the email_verification template exists and is published with an email-channel content containing (seeded by migration 029-seed-notify-templates.ts), and (3) PUBLIC_URL points at the front-end that hosts the /verify-email page.


Testing

End-to-end coverage in test/e2e/registration.test.ts: disabled registration, email validation, unverified → active flow, filter enforcement, anti-enumeration (existing accounts return 204), weak-password rejection, verification_url allow-listing, and token-scope validation.

ODP Internal API Documentation