# Sign-in and accounts

Choose who can create an account and how people sign in: allowlists, email verification, SSO,
passkeys and password rules.

## Who can sign up

```env
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=you@example.com,@company.com
```

`SIGNUPS_ENABLED` and `SIGNUP_ALLOWLIST` come from the operator's environment on every start. An empty allowlist clears any old stored list; signup order never grants privileges.

Server configuration belongs in the operator's environment. App accounts cannot change signup policy,
server integration credentials, or deployment updates, and cannot read deployment security logs.

There is no app-admin role, first-user privilege, or `ADMINS` list. Workspace owners manage only their own workspace.

`XIELING_ADMIN_EMAILS` names accounts, comma-separated, that review plugin submissions, add stdio
MCP servers ([integrations](/docs/self-host-integrations/)), and, on deployments that sell model
credits, give free access at `/admin/access`. An account counts only once its email is verified.
It grants nothing else.

A free-access grant names an email and gives up to $1,000 of model credits, up to 12 free own-key
months, or both. Whoever owns an organization with that verified email receives it the next time they
open the app, whether they signed up before or after the grant. A grant can be revoked until it is
received.

## Email verification and sign-in codes

With a nonempty signup allowlist and SMTP configured, users—including existing accounts—must
verify their email to sign in. Without an allowlist, verification never blocks: a password signup
signs in at once and is sent a verification link that only marks the address verified.

SMTP also enables sign-in with a six-digit emailed code, which expires after ten minutes and allows
three attempts. A code can create an account, so it follows the same registration and allowlist
rules as password signup. Signing in with a code to an existing unverified account removes that
account's password, SSO links and sessions, since nothing proved they belonged to the mailbox
owner. This is what stops someone who registered another person's address from keeping access.
A password reset proves the mailbox the same way, so resetting an unverified account's password
also removes its SSO links, passkeys and paired devices.

Allowlisted signup requires mailbox verification and email delivery, including the first account.
Without email delivery, use the server-side account bootstrap described below; public signup never marks an address verified or assigns server permissions.

## Installations without email

For an installation without email, first set `SIGNUPS_ENABLED=false` in `.env` and start the stack.
Create the intended account privately (the password stays in the shell environment):

```bash
read -r -p "Account email: " OWNER_EMAIL
read -r -s -p "Account password (at least 16 characters): " OWNER_PASSWORD
export OWNER_EMAIL OWNER_PASSWORD
docker compose --env-file .env -f docker-compose.images.yml exec -e OWNER_EMAIL -e OWNER_PASSWORD api node dist/cli/bootstrap-owner.js
unset OWNER_EMAIL OWNER_PASSWORD
```

For a source checkout, use `-f infra/compose/docker-compose.yml` instead. This creates an ordinary verified account and a personal workspace, with no server privileges. For an existing unverified account, its password must match before it can be verified; an existing verified account is left unchanged.
Keep an installation without email on a trusted local network.

## Verification and password recovery email

Password changes for signed-in users require no email configuration. Forgotten-password recovery
appears on sign-in only when a transactional email provider is available. Xieling uses a
provider-neutral contract with Resend API and SMTP adapters. Templates render locally with React
Email into HTML and plain text, independent of the sending provider.

To use the Resend HTTP API, verify a sending domain and create a sending-only API key restricted
to that domain, then configure:

```env
EMAIL_PROVIDER=resend
EMAIL_API_KEY=replace-with-sending-api-key
EMAIL_FROM=Xieling <hello@mail.example.com>
```

Disable click and open tracking for account email in the provider's domain settings. Resend delivery
has a ten-second deadline and bounded retries with the same idempotency key, including when a
response is lost. Errors omit provider response bodies and credentials.

For Amazon SES or another SMTP server, use:

```env
SMTP_URL=smtps://smtp-user:replace-with-password@smtp.example.com:465
EMAIL_FROM=Xieling <no-reply@example.com>
```

Set `EMAIL_REPLY_TO` to send replies somewhere other than `EMAIL_FROM`.

`EMAIL_PROVIDER=smtp` is optional: an existing `SMTP_URL` continues to select SMTP when no provider
is specified. `EMAIL_PROVIDER=none` disables external sending. Explicit provider settings take
precedence over an old `SMTP_URL`; incomplete or unknown provider configurations fail at startup.

Resend also supports SMTP: use `smtp.resend.com`, username `resend`, and an API key as the password. For Amazon
SES, use the regional SMTP endpoint and SES SMTP credentials; these are different from ordinary AWS
access keys. Verify the sender/domain with the provider before testing delivery. Keep credentials in
`.env`, never in tracked files. `smtps://` uses implicit TLS; `smtp://` is also supported but requires
STARTTLS. Xieling rejects configuration that disables TLS or certificate verification.

Local source development can use the offline email emulator instead. It captures email without
contacting a provider:

```env
EMAIL_EMULATOR=true
```

The emulator is forcibly disabled when `NODE_ENV=production` and requires the API to bind to a
loopback host. In `NODE_ENV=development`, captured messages are available from
`http://127.0.0.1:3100/api/dev/emails` with cache disabled; the API logs only delivery
metadata, never reset tokens. The inbox route is not registered in test, staging, or production.

Set `WELCOME_EMAIL_ENABLED=true` to send each new account one welcome email, with an unsubscribe
link, after it first signs in. It needs one of the providers above.

## Password policy

New passwords (signup, reset and change) must be 8 to 128 characters, with no composition rules.
Optionally reject new passwords that appear in known breaches:

```env
AUTH_BREACHED_PASSWORD_CHECK_ENABLED=true
```

This queries the Have I Been Pwned range API, sending only the first five characters of the
password's SHA-1 hash. It is off by default so the server makes no outside request. If the service
is unreachable or takes longer than three seconds, the password is accepted and the failure logged.

## OpenID Connect SSO

SSO works with a self-hosted or hosted OpenID Connect provider. Leave its settings unset to
keep password authentication alone. Configure all three credentials together on the API:

```env
OIDC_ISSUER=https://identity.example.com
OIDC_CLIENT_ID=replace-with-client-id
OIDC_CLIENT_SECRET=replace-with-client-secret
OIDC_NAME=SSO
OIDC_SCOPES=openid email profile
AUTH_PASSWORD_ENABLED=true
OIDC_ALLOW_SIGNUP_BYPASS=false
```

The issuer must be HTTPS and exactly match the discovery document's issuer. Xieling loads
`<issuer>/.well-known/openid-configuration`, verifies ID tokens against discovery JWKS with
issuer, audience and nonce checks, and uses authorization codes with PKCE. Additional scopes
may be space- or comma-separated; `openid email profile` are always requested. `OIDC_NAME`
is the button label, defaulting to “SSO”. Secrets remain on the API; Compose clears the worker's
OIDC credentials. The capabilities endpoint exposes only the label, discovery availability,
and enabled authentication methods.

Register this redirect URI at the provider (using your public `BETTER_AUTH_URL` origin):

```text
https://app.example.com/api/auth/callback/oidc
```

Web, Electron and mobile use the same provider redirect URI. Electron completes SSO in a
sandboxed in-app popup sharing the app's session, then returns to the main window. Mobile completes the callback
on the API, then returns to `xieling://sign-in` (or `xieling://account` for linking and reauthentication) through Better Auth's Expo authorization proxy
and a native auth browser session. The `xieling` app scheme is trusted by the auth server;
never register a client secret in the mobile app. Mobile stores the resulting session with
SecureStore, like password sign-in. Native builds need the Expo WebBrowser module.

Provider emails are verified only when `email_verified` is the boolean `true`. False or missing
claims stay unverified, including on subsequent sign-ins, and an unverified claim never changes
the account's email. Sign-in never links accounts by email, except to claim an account
[directory provisioning](#directory-provisioning-scim) created.
If an email belongs to another account, sign in to that existing account and choose **Link SSO**
in account settings. Linking requires an authenticated session, a verified provider email and
matching email addresses. With email delivery, an account links SSO only after its email is
verified. Without it, an unverified account links only from a browser signed in to it, so not
from the mobile app. The issuer and provider subject identify the linked account thereafter. Changing issuers does
not reuse an old identity. Old-issuer links remain stored but do not count as linked to the
current provider, so **Link SSO** becomes available again. Link the new identity from the
existing signed-in account; authenticated-session, verified-email and matching-email checks
still apply. Restoring the old issuer makes its existing links usable again.

Ordinary SSO sign-in and linking may reuse the identity provider's existing session. They
do not grant fresh authentication proof for account deletion. The account settings' explicit
reauthentication flow requests `prompt=login` and `max_age=0`, binds the callback to the
same linked issuer and subject, and requires the initiating session to remain valid. Only
that callback grants proof to the resulting session, expiring five minutes after provider
authentication (callback delays do not extend it). Its verified
ID token must contain an `auth_time` consistent with that request (allowing 60 seconds of
clock skew) and less than five minutes old. Missing, stale or invalid claims fail closed.
OAuth callbacks are single-use, including concurrent requests. Password verification and
emailed deletion codes remain available. Submit deletion codes through the account settings
or the CSRF-protected `POST /api/auth/delete-user` endpoint; the GET deletion callback is disabled.

SSO signup follows closed registration and the deployment allowlist before creating an account.
Allowlisted provider emails must be verified; signup never bypasses mailbox proof. `OIDC_ALLOW_SIGNUP_BYPASS=true` explicitly admits IdP identities
without applying the allowlist or its email-verification admission requirement. Enable it only
when the IdP controls who may join this deployment. It does **not** reopen closed registration,
and does not change email verification claims or linking rules. Existing admitted accounts can
sign in while registration is closed.

Set `AUTH_PASSWORD_ENABLED=false` for SSO-only operation. Password sign-in, signup, password
reset and password mutation endpoints are disabled server-side, and sign-in forms are hidden.
The API refuses to start in this mode unless all OIDC credentials are configured. It can still
start while discovery is temporarily unavailable: the provider remains registered, sign-in
returns a temporary error, and background retries recover without restarting. Discovery is lazy
on first use, refreshed in the background, and failures back off up to one minute. Availability
reflects discovery, not a guarantee that the provider's token endpoint is currently reachable.

Account deletion keeps the existing password confirmation for password users. Users without a
password may delete after explicit provider reauthentication within five minutes; **Sign in again** starts a fresh
provider round-trip bound to the signed-in account. Choosing a different identity cannot confirm
deletion or create another account in that flow. A stale or borrowed session alone cannot authorize deletion. With transactional
email configured, **Send deletion code** sends a single-use code to the account email, valid for ten
minutes. Enter it in account settings to confirm deletion. Email-code requests are rate-limited;
invalid, expired or wrong-account codes fail. No password needs to be created for deletion.

## Directory provisioning (SCIM)

Your identity provider can add people to Xieling and remove them over SCIM 2.0. Generate a token
(`openssl rand -hex 32`) and set it on the API:

```env
SCIM_BEARER_TOKEN=<your generated token>
```

In the identity provider, set the SCIM base URL to `https://app.example.com/api/auth/scim/v2`
(your `BETTER_AUTH_URL` origin) and the bearer token to that value. The token manages users only;
group requests are refused. Rotating it means changing the value and restarting the API.

- **Adding a person** creates an account with their email, unverified, and no password. It
  joins Xieling even while signup is closed or the allowlist would refuse it, so
  `SIGNUPS_ENABLED=false` with SCIM admits exactly the people the directory assigns. They sign in
  through [SSO](#openid-connect-sso) (the first SSO sign-in with the same verified email claims the
  account) or with an emailed sign-in code.
- **A person who already has an account** keeps it: the directory takes it over by email without
  changing its name, email or logins, and can then turn it off. Only an account whose email was
  verified is taken over. Anyone could have signed up first with an address nobody proved, so
  the directory gets a conflict for it, as it does when two accounts' emails differ only by case;
  the operator decides what to do with those accounts.
- **Turning a person off** (`active: false`) or **deleting** them deactivates the account at once.
  It ends every sign-in and paired computer, removes notification registrations, cancels runs
  they started, skips their routines, and refuses any new sign-in, run, webhook delivery or
  message from their linked chat apps. Their bots, chats, memory, files and connections stay.
  They also leave every shared space someone else invited them into, as if removed: what they
  made there passes to that space's owner, and their routines there are paused. Spaces they own
  stay theirs.
  Turning them back on restores sign-in and their own spaces, and routines there resume from
  their next scheduled time. Shared spaces they left need a new invite.
- **Adding a deleted person again** with the same external id finds their existing account.

Without an identity provider, the operator can do the same from the API container:

```bash
docker compose --env-file .env -f docker-compose.images.yml exec api \
  node dist/cli/account-status.js deactivate person@example.com   # or reactivate
```

While `SCIM_BEARER_TOKEN` is set, it leaves accounts the identity provider manages to the
provider: it will not turn off one the provider has on, or turn back on one the provider turned
off or deleted. Once the token is removed, it changes any account. The same command
finishes a deactivation whose cleanup failed. Sign-in and runs are refused as soon as the account
is off, and the API logs `directory.account_change.unfinished` with the account id if ending its
sessions, computers or runs did not complete.

Deactivation and reactivation are recorded as `auth.account.deactivated` and
`auth.account.reactivated` [security events](/docs/self-host-observability/#security-events) and in
each of the person's organizations' [audit log](/docs/self-host-audit-log/). The directory never
deletes an account or its content.

## Passkeys

Passkeys are off unless you set `AUTH_PASSKEYS_ENABLED=true`, so the account password is the
only credential by default. When they are on they work whenever `WEB_ORIGIN` is an HTTPS domain
(or `localhost` in development); the relying party is that origin's host, so existing passkeys
keep working only while `WEB_ORIGIN` stays on the same domain. They are off when password sign-in is disabled for SSO-only
deployments. On web, a browser offers them where it has a platform authenticator. The macOS
desktop app uses the system passkey sheet, which only serves the domain the release is
associated with (`app.xieling.app`), so it hides passkeys for other servers.

Native apps can use a domain's passkeys only when the domain vouches for them and the app was
built for that domain. The iOS app's associated domain comes from `EXPO_PUBLIC_API_URL` at build
time. A macOS desktop build takes it from `XIELING_PASSKEY_RP_ID` (default `app.xieling.app`): set
it to the same domain for `pnpm build` and for `scripts/passkey-entitlements.ts`, then package with
electron-builder's `-c.mac.entitlements` set to that script's output and `-c.mac.provisioningProfile`
set to a profile for your App ID with the Associated Domains capability, as the release workflow
does. Without both, macOS ignores the association and the app hides passkeys. When Apple's CDN
cannot reach the server (a private network or a VPN such as Tailscale), also set
`XIELING_PASSKEY_DEVELOPER_MODE=1` for either build: a development-signed app then fetches the
association file itself, once associated-domains developer mode is on (Settings > Developer on
iOS, `sudo swcutil developer-mode -e true` on macOS, where the app must also live in
`/Applications`). To let a build use this server's passkeys, set:

```bash
AUTH_PASSKEYS_ENABLED=true
# iOS and the macOS desktop app (app.xieling.desktop): <Apple Team ID>.<bundle id>, comma-separated.
# Served as /.well-known/apple-app-site-association.
PASSKEY_APPLE_APP_IDS=ABCDE12345.com.xieling.app,ABCDE12345.app.xieling.desktop
# Android: SHA-256 fingerprints of the app signing certificates, comma-separated.
# Served as /.well-known/assetlinks.json and accepted as passkey origins.
PASSKEY_ANDROID_CERT_SHA256=AA:BB:...:FF
```

Until a platform's setting is set, the native app on that platform hides passkeys for this
server. Both files are served through the web origin, so a reverse proxy in front of the web container
must pass `/.well-known/` through to it.
