Auth
Better Auth with five plugins, a role column the API actually reads, and per-user scoping as the security boundary.
Auth is Better Auth. Five plugins are enabled, and each adds a real sign-in path rather than a configuration option:
| Plugin | What it gives the user |
|---|---|
admin | A role column on user, admin actions, and RBAC |
magicLink | Sign in from an emailed link, no password |
emailOTP | Sign in with a one-time code |
twoFactor | TOTP, with the app name as the issuer |
passkey | WebAuthn, with the web origin as the relying party |
Magic links and one-time codes need email configured. Until
RESEND_API_KEY is set, those options do not appear, because a login method
that cannot deliver its own link is worse than a missing button.
Try all of it on the demo under account settings, which is faster than reading the rest of this page.
Single-user by default
The base product has no organizations. There is no organization plugin and
no activeOrganizationId; every resource is owned by the user who created it.
That is a deliberate floor rather than an omission. Multi-tenancy changes every
query in the codebase, and a boilerplate that guesses wrong about your tenancy
model costs more than one that leaves it out. Organizations, members and
invitations are restorable from the multitenancy skill in
.claude/skills/multitenancy.
Ownership is the security boundary
Every route gets user, session and isAuthenticated from the shared
context (apps/api/src/lib/context.ts, via auth-context.ts).
// Right: the id comes from the session
.where(eq(projects.ownerId, user.id))
// Wrong: the id comes from the caller
.where(eq(projects.ownerId, body.userId))Never take an owner id from a body, a query parameter or a path segment. The whole scoping model rests on that id coming from the session, and one handler that reads it from the request undoes it for the entire resource.
Routes that legitimately read across users are gated by admin RBAC through the
rbac macro in apps/api/src/lib/rbac.ts, not by a filter the caller controls.
RBAC
Permissions are declared as statements in
packages/auth/src/access-control.ts — a map of resource to allowed actions:
user: ["create", "list", "read", "update", "delete", "set-role", "ban"]
session: ["list", "revoke"]
content: ["create", "read", "update", "delete", "publish"]
posts: ["create", "list", "read", "update", "delete", "publish"]
role: ["create", "read", "update", "delete"]
dashboard: ["access"]
waitlist: ["list", "export"]Roles are combinations of those. The default role is user; admin is the
privileged one.
There is no roles table. The role lives in the user.role text column, and
that column is what the access gate reads. Granting admin to somebody is a
single update:
update "user" set role = 'admin' where email = '...';If you add role or permission tables expecting them to be consulted, they will not be.
Email change is a two-step
changeEmail is enabled, and the flow sends a verification link to the new
address. The change commits only when that link is clicked.
Do not shortcut it by writing to user.email directly. The verification is
what stops a hijacked session from silently moving an account to an attacker's
address, which is the whole reason the flow exists.
Configuration that bites
| Setting | Consequence |
|---|---|
NEXT_PUBLIC_APP_URL | Better Auth appends /api/auth itself. A base URL already ending in /api produces /api/api/auth/... and every request 404s. |
TRUSTED_ORIGINS | Checked before the password is. A host missing from the list turns correct credentials into "invalid credentials". |
USE_SECURE_COOKIES | Read by both the server and the admin's cookie-name lookup. Set one way and served the other, the session is issued and then not found. |
cookieSameSite: "lax" | The admin and the API must share a hostname. A different origin never receives the cookie. |
BETTER_AUTH_SECRET | Rotating it signs every existing user out at once. |
The passkey relying party and the TOTP issuer both derive from the web origin, so changing your domain invalidates enrolled passkeys. Plan that with a migration window rather than a deploy.