Skip to content

Commit ceca119

Browse files
committed
docs(deployment): state the address-enumeration disclosure the open audience posture carries, by design (#15746)
The `open` posture's sign-up endpoint tells any caller whether an address is already registered: a registered address is refused `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` while an unregistered one is accepted `200`. That is a property of the posture, not a defect in the refusal, and until now it was documented nowhere an operator reads. The self-hosting page gains one section: the three-posture disclosure table (each row measured against the real sign-up route), the statement that `open` discloses registration by design, the honest-refusal rationale — the explicit 422 replaced a silent 200 for a row that was never written — the counterweight that the silent lane was never a real secret either (the engine equalised the password hash and nothing else, so the verification mail that never arrives already told a patient caller the same thing), and better-auth's designed alternative `onExistingUserSignUp` as the remedy an operator can request. Documentation only. No behaviour changes, no accept-set changes, and no edit to the generated reference page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent abdceef commit ceca119

1 file changed

Lines changed: 44 additions & 0 deletions

File tree

content/docs/deployment/self-hosting.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,50 @@ logins too, or leave the directory empty until the first administrator exists.
574574
A deployment that boots with zero human rows is the case both paths above
575575
describe, and it needs none of this.
576576

577+
## Who may sign up — and what each posture tells a stranger
578+
579+
`auth.audience.posture` is the one declaration answering who may become a user
580+
of this deployment: `invite_only` (the default), `email_domain`, or `open`
581+
(schema: [Auth Config](/docs/references/system/auth-config)). It also decides
582+
what the sign-up endpoint tells someone who is **not** a user yet. Measured on
583+
a populated deployment, posting to `/api/v1/auth/sign-up/email`:
584+
585+
| Posture | What a stranger learns |
586+
|:---|:---|
587+
| `invite_only` (default) | **Nothing.** A registered address and an unknown one both answer `403 SELF_REGISTRATION_CLOSED` — same body bytes, same headers. |
588+
| `email_domain` | **Only inside a domain you allowlisted.** Any address outside the list answers `403 EMAIL_DOMAIN_NOT_ALLOWED`, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything. |
589+
| `open` | **Whether any address is already registered.** A registered address answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`; an unregistered one answers `200`. |
590+
591+
**On `open`, that disclosure is by design.** Anyone who can reach the endpoint
592+
can test an address and learn whether it belongs to a user of this deployment,
593+
one address per request, holding no account of their own. On an
594+
internet-facing deployment that is an address-enumeration oracle. It is a cost
595+
of the posture rather than an oversight: the endpoint refuses **honestly**, and
596+
what that refusal replaced was worse for everyone except the enumerator. A duplicate
597+
sign-up used to answer `200` carrying a freshly minted user id for a row that
598+
was never written — no account, no verification mail, and the person's next
599+
sign-in `401` with nothing anywhere explaining it.
600+
601+
**That silence was never a real secret, either.** The auth engine hashed the
602+
password on both lanes to level the timing and stopped there: a genuine
603+
registration also writes rows and dispatches a verification mail, so the mail
604+
that never arrives — and the account that can never sign in — already told a
605+
patient caller what the `422` now states outright. The explicit refusal made an
606+
existing disclosure legible; it did not create it. `invite_only` and
607+
`email_domain` are untouched by it: the uniqueness refusal is reached only for
608+
a caller the posture has already admitted, so a stranger on the default still
609+
gets `403 SELF_REGISTRATION_CLOSED` and learns nothing.
610+
611+
**If you need `open` without the disclosure, ask for it.** better-auth, the
612+
auth engine underneath, ships the designed alternative: `onExistingUserSignUp`,
613+
a hook that pairs a generic sign-up response with a notification to the
614+
address's real holder — "someone tried to register with your email". It is
615+
deliberately **not wired** here, because turning it on trades the honest
616+
refusal above for a silent success, which is the shape that behaviour exists to
617+
remove. If you run a real internet-facing `open` deployment and want that
618+
trade, open an issue saying so: it is a per-deployment decision, waiting on a
619+
deployment that needs it.
620+
577621
## Go-live
578622

579623
Before pointing real users at the deployment, walk the

0 commit comments

Comments
 (0)