Authentication

Better Auth sessions, passwordless by default, optional Google and Apple, and the web and native clients.

Onekit uses Better Auth with PostgreSQL sessions. It is passwordless by default: the sign-in screen asks for one identifier and emails a six-digit code, and the first code creates the account. Google and Apple appear when their server credential pairs are configured. The browser calls the current site’s /api/auth/* routes; the native client uses the same API and stores its session cookies with Expo SecureStore.

Start locally

Complete the database setup in Database, then set these values in your untracked .env:

DATABASE_URL=postgresql://onekit:onekit@localhost:54329/onekit
BETTER_AUTH_URL=http://localhost:8081
ONE_PUBLIC_SERVER_URL=http://localhost:8081
BETTER_AUTH_SECRET=<a-random-secret-at-least-32-characters-long>

Generate a secret with openssl rand -base64 32. Start the server with bun run dev. The first auth request validates configuration; importing server files and building static pages does not need a database connection or secret.

BETTER_AUTH_URL is the canonical server origin with no path. It must use HTTPS in production. ONE_PUBLIC_SERVER_URL is public, is embedded into native builds, and must be reachable from the device. An iOS simulator can use localhost; an Android emulator typically uses http://10.0.2.2:8081; a physical device needs your development machine’s LAN address. For OAuth development, use one canonical reachable origin consistently for the server, callback registration, and native API URL. Native release builds require an HTTPS URL. Restart development/build processes after changing environment values.

Ways in

Two switches decide what the sign-in screen offers, and they read each other so that no configuration both boots and refuses everybody.

EMAIL_PROVIDEROAuth pairAUTH_PASSWORDAUTH_EMAIL_SIGN_INThe screen
setanyemptyemptyOne identifier field, emailed code. The shipped default
setanytrueemptyEmail and password, no code route
setanytruecodeBoth, with “Email me a code instead” beside the password form
emptysetempty—OAuth buttons only
emptyemptyempty—Email and password: it is the last door standing
anynonefalseoffRefused at startup — nothing could sign in

AUTH_EMAIL_SIGN_IN=link is in the type and not implemented; it is refused rather than accepted and ignored. The code route needs EMAIL_PROVIDER because the code has to be delivered.

Turning AUTH_PASSWORD off on a deployment that already has password users is a day-one choice, not a safe flip: only sign-in and sign-up consult it, so the settings page would otherwise go on changing a password nobody can sign in with. The four password-management routes are closed with it for that reason, and answer 404.

The code route creates the account it signs in, with a verified address and an empty name — a code is all it was given. Anything deriving a display name from user.name has to cope with that.

Signing in with a code

import { authClient } from '@/auth/client'
await authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' })
await authClient.signIn.emailOtp({ email, otp })

Codes are six digits, expire after ten minutes, allow three attempts, and are single use. Asking again resends the same code and extends its expiry rather than issuing a new one, so every message in the inbox shows a code that works.

Signing in with a texted code

AUTH_PHONE_SIGN_IN=true plus an SMS_PROVIDER makes the one field accept a phone number too, and the label says so.

An account always has a real email address, and a phone number is a second identity rather than a first one: the phone route signs people in and never creates them. A number is attached and verified from the profile page while signed in, which is the only way one becomes usable.

That is a deliberate choice with two reasons. Better Auth can create an account on phone verification, but only by inventing a synthetic email address — and an account whose address is fake can never be deleted, because /delete-user falls through to an emailed confirmation, and never receives a receipt or a reset. It is also the SMS-spend defence: a number nobody has verified is never texted, so pointing this endpoint at a premium range costs nothing. That is the whole mechanism of SMS pumping fraud.

A number that is not on any account gets exactly the answer one that is gets — the code step appears, and no message is sent. Saying “no such number” would make the endpoint a directory of who has an account.

await authClient.phoneNumber.sendOtp({ phoneNumber })
// Signing in: no `updatePhoneNumber`, so this means "sign me in as whoever owns this number".
await authClient.phoneNumber.verify({ phoneNumber, code })
// Attaching, from a session: the same endpoint, with the flag.
await authClient.phoneNumber.verify({ phoneNumber, code, updatePhoneNumber: true })

signIn.phoneNumber is not this — it is phone plus password, and it is closed along with the plugin’s two password-reset routes wherever AUTH_PASSWORD is off.

How many codes get sent

A code route is a button that spends money on demand, so two windows gate it, in src/server/send-quota.ts:

WindowLimitWhy
Per address or number, per ten minutes3Stops one identifier being hammered, and stops a resend button being a send button
Per channel, per day500 email, 100 SMSA per-identifier cap is no defence against a thousand different numbers, which is the shape of SMS pumping fraud

Per-identifier first: only a request that passes it draws on the day’s budget, so one hammering client cannot burn the ceiling for everybody else. On the phone route the two halves are claimed around the “is this number anybody’s” check, which is what keeps both properties — a request that sends nothing never spends the shared ceiling, and an unknown number still answers 429 on its fourth attempt exactly as a known one does. A refused request answers 429 with Retry-After, and the refusal is identical for an address that has an account and one that does not — the check runs before the route’s own user lookup, so it cannot be used to find out who has an account.

It runs as a Better Auth before hook rather than inside the send callback, because the plugin writes the verification row and then calls the callback: a limiter in the callback would already have paid for a row and replaced the recipient’s live code with one nobody receives.

Raise either limit in SEND_QUOTA. The counter table is send_quota, one row per bucket, pruned when the daily window rolls over. The identifier the field accepts is read by readSignInIdentifier in src/auth/identifier.ts, the one rule that decides whether a string is an address or a phone number and what its canonical form is; src/auth/sign-in-schema.ts is the same rule as the form’s zod schemas. A phone number parses today and is refused at send: SMS delivery is not wired yet.

To read a code without a mail provider, point the transport at a file:

EMAIL_PROVIDER=file
EMAIL_OUTBOX=.onekit/outbox.jsonl

Each message is appended as one JSON object per line. Both console and file are refused when NODE_ENV=production — these messages contain live credentials.

Use the client

import { authClient } from '@/auth/client'
await authClient.signUp.email({ name, email, password })
await authClient.signIn.email({ email, password })
await authClient.signOut()
const { data: session, isPending, error } = authClient.useSession()

These two need AUTH_PASSWORD=true; without it they answer 404. Each mutation returns { data, error }; handle the error before navigating. Passwords are at least eight characters, a length MIN_PASSWORD_LENGTH in src/auth/sign-in-schema.ts states once for both the form and the server. Password reset, email confirmation, password change, and account deletion are wired through the optional email module; see Email for the provider setup, the verification policy flag, and each client call. The starter does not include organization membership or roles.

For app API calls:

import { authenticatedFetch } from '@/auth/api'
const response = await authenticatedFetch('/api/me')
if (response.status === 401) {
// Ask the user to sign in.
}

The helper sends same-origin cookies on web and the SecureStore cookie on native. It accepts only app-relative /api/ paths. /api/me checks the session on the server and returns { user }, or HTTP 401. Keep this server check on every protected API: hiding a screen is not authorization. Auth responses are marked Cache-Control: no-store.

Enable an OAuth provider

Four are wired. Set both variables for each provider you want, and restart the server:

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

Leaving both values empty disables that provider. A partial pair is a configuration error. /api/auth-providers exposes booleans only — no client id, no secret, no address — so the sign-in screen follows server configuration without shipping credentials; useAuthProviders() in src/auth/providers.ts reads them, and the endpoint’s exact shape is asserted by the starter e2e spec so a new flag cannot reach the wire unnoticed.

Register ${BETTER_AUTH_URL}/api/auth/callback/<provider> with each one. Apple requires HTTPS and a real domain for callbacks; APPLE_CLIENT_ID is the Service ID and APPLE_CLIENT_SECRET is a signed Apple JWT that must be replaced before its expiration.

Microsoft takes one option past the pair, and its default is a decision rather than a detail:

# Unset: Microsoft's `common` authority — any personal or work account in the world.
# Set: only this Entra directory can sign in.
MICROSOFT_TENANT_ID=

For a deployment serving one company’s staff, set it. The tenant GUID is not a secret, so for SST it lives in infra/config.ts as oauth.microsoftTenantId rather than in a secret.

The bundled social sign-in path uses Better Auth’s browser-based flow:

await authClient.signIn.social({ provider: 'google', callbackURL: '/settings/profile' })

The Expo plugin turns the native callback into an app deep link. appConfig.scheme must match the scheme in the native build. Rebuild native apps after changing it. Native SDK ID-token sign-in is not included. Adding Google’s native SDK would require iOS/Android OAuth client IDs. Adding Apple’s native SDK would require an audience matching the complete bundle identifier, including .development or .preview for those app variants; the browser-based flow uses the Service ID and does not require that audience.

SST maps configured provider secrets into the same environment variable names. See Deployment for its provider toggles and stage secrets.

Configuration and boundaries

Edit src/server/auth.ts for the enabled methods, session duration, trusted origins, email hooks, and plugins. readAuthPolicy in src/server/env.ts is where the truth table above lives, and /api/auth-providers publishes the resulting booleans — never credentials — for the sign-in screen to branch on. Sessions last seven days and refresh after one day. Password reset and password change revoke other sessions. Better Auth’s default production rate limiter is per process; add shared rate-limit storage when running multiple server instances. No broad wildcard web origins or cross-site CORS policy are enabled.

An exception escaping a route answers one of three things, and the differences are deliberate. A ServerConfigurationError is 503, because a deploy can fix it and a client should retry. An exhausted connection pool is also 503, with its own log line naming the condition — every workspace request holds one connection for its whole life, so DATABASE_POOL_MAX bounds concurrent requests, and saturation logged as an unhandled error is indistinguishable from a bug in exactly the incident where telling them apart decides whether you raise the pool or shorten the request. Anything else is 500 — a bug does not fix itself, so retrying is wasted. In every case the client learns only the status; the route, the error and its stack go to the log.

Workspaces and roles

Every account gets its own workspace when it is created, and every CRM row belongs to one. Which workspace a request is about is resolved from the session before any handler runs, and membership is re-checked on every request — so a member whose access is revoked stops reading immediately rather than when their session expires.

Four roles, defined once in src/auth/permissions.ts and read by both sides:

recordspipelinesviewsworkspacebilling
ownerallallallupdate, deleteread, manage
adminallallallupdateread
memberallreadcreate, read, update——
viewerreadreadread——

The server authorizes through context.can(...) and answers 403; the client reads the same table through usePermissions(), fed by /api/me. One definition, so a disabled control and a 403 cannot disagree.

A new account is walked through onboarding in Twenty’s order: name the workspace, then yourself, then invite the team. The first two are derived from what is missing — the workspace still has its placeholder name, or user.name is empty — so a reload mid-signup lands in the right place and the step disappears when the thing it asked for exists. Inviting is not a state: a workspace with one member is the normal condition of somebody working alone, so it sits at the end of the flow and is skippable rather than forced by a condition.

A workspace holds up to 100 members — MEMBERSHIP_LIMIT in src/auth/permissions.ts, passed to the plugin rather than left to its default so that every way in reads the same number: accepting an invitation, being added directly, and joining by email domain. Invite from Settings › Members — the invitation is emailed when EMAIL_PROVIDER is set, and the row is written either way so an admin can share the link by hand. An invitation is answered at /invitations/<id> and can only be accepted by a session whose address matches it, so forwarding the link achieves nothing.

Letting a company’s own domain in

An owner or admin can turn on Settings › Members › Joining by email domain, after which anybody who signs up with an address at that domain is offered the workspace as their first onboarding step, before being asked to name one of their own. It is how the seventh person at a company avoids setting up an eighth workspace.

Three things about it are deliberate, and the middle one is a limit rather than a feature:

  1. Public mail providers can never be claimed. src/auth/email-domains.ts holds the list — consumer, ISP and disposable domains, plus the country domains of the big providers, since yahoo.co.uk is the same product as yahoo.com. It is a floor, not a complete list; add to it for a deployment that cares. The rule is applied when a claim is written and when one is read, so adding a domain to it takes effect everywhere on the next request.
  2. Having an address at a domain is not owning it. The toggle claims the domain of the signed-in admin’s own verified address and has no field to type a domain into, so nobody can claim a domain they have no address at — but anybody with an @acme.com address can claim acme.com. That is a bound on who could abuse it, not proof of ownership. Real verification is a DNS TXT record checked at claim time, which belongs with SSO and is not in the kit. Both sides require a verified address, which matters because AUTH_REQUIRE_EMAIL_VERIFICATION is off by default: an unverified address would make the bound worth nothing.
  3. The newcomer is offered the workspace, never joined to it. Signing up with a work address and finding your manager already sees you in a member list is a surprise nobody consented to. They join as a member, and declining takes them to the ordinary naming step.

One workspace per domain, held by a unique constraint rather than by a check — a second workspace claiming a taken domain is answered with a 409 naming it. Joining leaves the account’s own placeholder workspace in the workspace switcher: the kit creates that workspace with the account so the CRM is reachable from the first request, and it does not delete workspaces on somebody’s behalf. Rename it or leave it.

Tenancy is enforced in the application — one organization_id predicate per query, in one place — and the eight CRM tables also carry a PostgreSQL row-level policy as a floor under a mistake. Each request is one transaction that tells the database which workspace it is for, so a query that lost its scope returns zero rows rather than somebody else’s. It is a backstop and not the mechanism: see Row-level security for the role a deployment has to connect as for it to do anything at all.

Statement-level only. “May update a view” is a permission; “may update this view” is a query predicate — favorites and saved views are private within a workspace, and that is enforced where the rows are read rather than by a role. An unrecognised role string reads as viewer.

src/server/* runtime modules use One’s server-only boundary; never import them into client components. The Drizzle schema contains only metadata and remains importable by migration tools. Standalone Node/Bun tests that import guarded runtime modules need the react-server export condition; the One build handles this boundary itself.

Reference: Better Auth Expo integration, Drizzle adapter, Google, Apple, One authentication routes.

Onekit / A little less setup.Web · iOS · Android