Authentication & Authorization
better-auth setup, sessions, and where each access check actually lives.
What better-auth is
better-auth is a self-hosted authentication library for TypeScript apps - it handles password hashing, session cookies, email verification, and password reset for you, storing everything in your own database through an adapter (here, the Drizzle adapter) rather than depending on a third-party auth provider. You own the user table and the data; better-auth just provides the (well-tested, security-sensitive) logic around it.
It's worth distinguishing two related but different ideas this page covers:
- Authentication - "who is this user?" (better-auth's job: sessions, login, verification)
- Authorization - "is this user allowed to do this?" (this app's job: account status and vault access - built on top of the session better-auth provides)
Single access tier
Server configuration (src/lib/auth/auth.ts)
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite", schema }),
emailAndPassword: { enabled: true, requireEmailVerification: true },
plugins: [emailOTP({ otpLength: 6, expiresIn: 600 })],
user: {
additionalFields: {
isActive: { type: "boolean", defaultValue: true, input: false },
approved: { type: "boolean", defaultValue: false, input: false },
},
changeEmail: { enabled: true },
},
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(","),
});Key points:
emailAndPasswordis the only sign-in method - no OAuth/social login is configured.requireEmailVerification: truemeans a new account can't log in until it clicks a verification link/code.- The
emailOTPplugin is reused for two purposes: verifying a new email address, and resetting a forgotten password - both send a 6-digit code (sendEmail, via Resend) rather than a magic link. additionalFieldsboth haveinput: false- this means a client cannot set these fields through the normal sign-up/update-profile API, even if it tries to send them in the request body. They can only be changed by server-side code writing to the database directly (e.g.PATCH /api/admin/users/[id], or the seed script). This is what stops a user from approving or reactivating their own account by crafting a request.
What each additional field means
| Field | Default | Meaning |
|---|---|---|
isActive | true | Set false to deactivate an account without deleting it - middleware.ts immediately redirects deactivated users to /login and clears their session cookies. |
approved | false | New sign-ups aren't approved by default - unapproved users are redirected to /pending until an existing member approves them from the Members page. |
Regenerating the schema
src/lib/db/auth-schema.ts (the user/session/account/verification tables) is generated from this config, not hand-written:
$ pnpm auth:generateRun this after changing anything in auth.ts that affects the user shape (like adding another additionalFields entry), then run pnpm db:generate + pnpm db:migrate as usual to turn the resulting schema change into an applied migration.
Client side (src/lib/auth/client.ts)
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), emailOTPClient()],
});
export const { signIn, signOut, signUp, useSession } = authClient;inferAdditionalFields<typeof auth>() is what gives useSession() correct TypeScript types for isActive/approved on the client, inferred from the same server config rather than redeclared.
The two layers of access control
This is covered in more depth in Architecture Overview - summarized here from the auth side:
middleware.ts- session exists? account active? approved? That's the whole check - once all three are true, a user can reach any page covered by the matcher, there's no further per-route gating.- Individual API route handlers each call
getSessionUser()(src/lib/auth/session.ts) and re-check the session/isActivethemselves, since/api/*isn't covered by the middleware at all.
There is no shared requireAuth() helper for route handlers - the same-shaped check is repeated per file. If you're adding a new API route, copy the pattern from a neighboring route.ts rather than inventing a new shape.
Helpers in src/lib/auth/session.ts
export async function getSessionUser() {
/* wraps auth.api.getSession({ headers }) */
}
export function canReadVaultEntry(user, entryId): boolean {
/* true only if a matching vaultEntryAccess row exists - no bypass */
}canReadVaultEntry is the one place authorization is still per-user rather than all-or-nothing - see Security for how vault entry grants work.