Skip to content

Login with Better Auth

protobase/server ships a Better Auth setup (version 1.7): email and password sign-in, roles, and short-lived JWTs that the API verifies against Better Auth’s own JWKS.

import { betterAuthAuthenticator, createAdmin, createAuth } from 'protobase/server'
const auth = createAuth({ database: pool, baseURL: 'https://admin.example.com', secret: process.env.BETTER_AUTH_SECRET! })
const admin = createAdmin({ resources, db, auth, authenticate: betterAuthAuthenticator({ auth, tenant: 1 }) })

createAdmin({ auth }) mounts Better Auth at /api/auth/* next to the API at /api/v1.

Users, sessions and signing keys stay apart from the application’s tables. The ERP example keeps them in an auth schema of its own database, so a host that gives an application one database (and a role that cannot create databases) is enough:

createAuth({ database: { dialect: new PostgresDialect({ pool }), type: 'postgres', schemaName: 'auth', transaction: true }, ... })
Terminal window
pnpm --filter erp auth:migrate # creates the auth schema and Better Auth's tables; needs only a role that owns the database; safe to repeat

ADMIN_DATABASE_URL puts the auth schema in another database instead of DATABASE_URL’s. createAuth takes a pg Pool or { dialect, type: 'postgres', schemaName? } as database; without schemaName the tables go to the connection’s search_path (usually public).

Variable
BETTER_AUTH_SECRET signs cookies and encrypts the token keys; openssl rand -base64 32; never committed (.env.example has a placeholder)
BETTER_AUTH_URL public URL of the API (default http://localhost:5173); issuer and audience of the tokens; when tunnelling, the https tunnel URL
TRUSTED_ORIGINS extra comma separated origins; https://*.trycloudflare.com is always trusted
  • Cookies are SameSite=Lax, and Secure when BETTER_AUTH_URL is https.
  • Sign-in is rate limited (5 attempts per minute and client by default, signInPerMinute); the limiter keys on x-forwarded-for, so run behind a proxy that sets it.
  • Passwords need at least 12 characters.

Accounts are never created over the web while the store is empty: there is no setup route and no public sign-up, so a freshly deployed URL cannot be claimed by whoever reaches it first. The first admin is created on the host, with the CLI (protobase users create [email protected] --generate-password) or from code:

import { createUser, hasUsers, listUsers } from 'protobase/server'
await createUser(auth, { email, password, name?, role? }) // the first user is always an admin; later ones default to `user`
await hasUsers(auth) // boolean
await listUsers(auth) // [{ id, email, role, banned, createdAt }], never passwords
await setUserRole(auth, { email, role }) // 'admin' | 'user'
await setUserBanned(auth, { email, banned }) // banning ends the user's sessions and blocks sign-in
await deleteUser(auth, email) // with sessions and accounts

The last active admin cannot be deleted, demoted or banned: those functions throw instead, so every caller is protected.

Until a user exists sign-in fails, and GET /api/auth/status (public) answers { "needsAdmin": true } so a login page can say what to do. Further users are created by an admin with Better Auth’s POST /api/auth/admin/create-user, or with createUser on the host. The auth store holds a connection pool; scripts that call these functions end the process themselves.

The roles are a project setting: createAuth({ ..., roles: ['admin', 'auditor', 'sales', 'accountant'] }). admin is always included and first; the default is ['admin', 'user']. Names are lowercase letters, digits, _ and -. defaultRole is what a new user gets when no role is given. Without it, and with more than one role besides admin, creating a user without a role is an error that lists the choices (the CLI then needs --role); with exactly one other role, that role is the default. auth.defaultRole is undefined when a role must be chosen. A user can hold several roles (setUserRole(auth, { email, role: 'sales,accountant' })).

createUser, setUserRole and Better Auth’s own admin endpoints refuse a role outside the list. roleChoices(auth) returns the list, for the CLI’s users set-role and for UIs. Only admin gains Better Auth admin-plugin permissions (managing users); every other role is for your access rules, which see them as ctx.user.roles.

Sign in at POST /api/auth/sign-in/email, then GET /api/auth/token (with the session cookie) returns { token }, an EdDSA JWT valid for 15 minutes; the keys are at /api/auth/jwks. Send it as Authorization: Bearer <token>.

betterAuthAuthenticator maps the claims to a session: sub is the user id, roles come from the role claim, which is the admin plugin’s role column on the user (a comma separated string; the first user is always admin). Access rules see them as ctx.user.roles. The tenant is the configured tenant option, never a claim, until organizations are supported. The key set is cached in memory: read once, again every 10 minutes (so removed keys stop being accepted), and again when a token names an unknown kid (a rotated key), at most once per 30 seconds however many such tokens arrive (cache: { refreshMs, minReloadMs }). Pass jwksUrl and issuer instead of auth when the API runs apart from the auth server.

jwtAuthenticator accepts asymmetric signature algorithms only unless you pass algorithms explicitly (the tests do, for HS256), so a token cannot be forged with a public key used as an HMAC secret; Better Auth signs with its own key pair and publishes the public keys at /api/auth/jwks. An expired, tampered, wrongly signed or missing token is a 401 problem with WWW-Authenticate: Bearer.

There is no development login. For scripts, mint a token for an existing user on the host (the CLI exposes it as protobase token [email protected]):

import { issueToken } from 'protobase/server'
const { token, expiresAt } = await issueToken(auth, { email: '[email protected]', ttlSeconds: 3600 }) // default 15 minutes, at most 24 hours

It is signed with the same keys and carries the same claims as a token from /api/auth/token, so the API treats it as any other. Banned and unknown users get an error. There is no HTTP route for this.

Only the Better Auth routes under /api/auth/* (sign-in and the like) and GET /api/auth/status are reachable without a token. The API (/api/v1/*) and the system endpoints (/api/meta, /api/openapi.json, /api/docs) all answer 401 without one.

Roles are bundles of capabilities (defineRoles in protobase/schema: orders.read.own, costs.read, *.read, …). Pass the result as options.roles to createAdmin, and every operation without an explicit rule needs the matching capability. The token’s roles become ctx.user.roles in rules; the user id (sub) is what .own compares with the resource’s .owner(...) field, so it must be the id that field stores. How the API enforces them (hidden fields, row filters, record permissions) is in the REST API reference.