ShipKit docs
The stack

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:

PluginWhat it gives the user
adminA role column on user, admin actions, and RBAC
magicLinkSign in from an emailed link, no password
emailOTPSign in with a one-time code
twoFactorTOTP, with the app name as the issuer
passkeyWebAuthn, 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

SettingConsequence
NEXT_PUBLIC_APP_URLBetter Auth appends /api/auth itself. A base URL already ending in /api produces /api/api/auth/... and every request 404s.
TRUSTED_ORIGINSChecked before the password is. A host missing from the list turns correct credentials into "invalid credentials".
USE_SECURE_COOKIESRead 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_SECRETRotating 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.

On this page