Authentication

Self-hosted authentication with organizations, powered by Better Auth.

kreogen uses Better Auth with the organization plugin. It runs entirely against your own database — there is no vendor account, no per-seat cost and no third-party branding in the sign-in flow.

What you get

  • Email and password, with mandatory verification and password reset
  • Magic links, delivered through Resend
  • Optional GitHub and Google sign-in, enabled by setting their credentials
  • Organizations with members, roles and email invitations
  • An admin plugin providing user listing, banning and impersonation
  • Redis-backed rate limiting on the credential endpoints

Modules

ImportRuntimeUse for
@kreogen/auth/sessionserverauth(), requireSession(), requireOrganization()
@kreogen/auth/clientbrowserauthClient, useSession, signIn, signOut
@kreogen/auth/serverserverThe Better Auth instance, for auth.api.*
@kreogen/auth/proxyedgeauthMiddleware, DEFAULT_PUBLIC_ROUTES
@kreogen/auth/handlersserverThe route handlers, mounted by apps/app only
@kreogen/auth/organizationsserverMember listing and search
@kreogen/auth/components/*browserSign-in, sign-up, switcher, members table

Choosing a helper

Four functions, in increasing order of what they guarantee. Reach for the strongest one the route can use.

import {
  auth,
  requireSession,
  requireOrganization,
  requireOrgRole,
} from '@kreogen/auth/session';

auth() returns { userId, orgId, user, sessionId, redirectToSignIn }, any of which may be null. For a page that renders differently when signed in rather than requiring it. Its honest name is getAuthState; auth is an alias kept so existing call sites did not have to change.

requireSession() redirects to sign-in when there is none, and returns { user, session, orgId } with user non-null.

requireOrganization() additionally guarantees an organization, and hands back the tenant-scoped database client:

const Page = async () => {
  const { db, orgId, user } = await requireOrganization();

  const pages = await db.page.findMany();
};

Returning the scoped client rather than a bare id is the point. An orgId that has to be threaded into every where clause by hand is one somebody eventually forgets — see tenant scoping.

requireOrgRole('admin') additionally checks membership and role, ranked member < admin < owner.

Better Auth checks roles at its endpoints, not at yours. A settings page that renders a members table without requireOrgRole is relying entirely on the table's own API calls failing — which leaks the page and its contents to anyone who guesses the URL.

Redirect targets are validated before use. Only a path on this site is accepted: an absolute URL, or the protocol-relative //evil.example that a naive startsWith('/') check lets through, would turn the sign-in page into an open redirect.

Organizations

Every user belongs to at least one organization, and the invariant is maintained in two places.

On sign-up, a user.create.after hook creates one and adds the user as its owner. Doing it there rather than on first sign-in means the member row exists before any session hook looks for it — for social sign-up as much as for email and password. The slug is derived from the name or email local part; it is unique, so a collision retries with more entropy rather than failing the sign-up.

On sign-in, a session.create.before hook sets activeOrganizationId from the user's earliest membership, falling back to provisioning one for accounts that predate the hook or whose only organization was deleted.

activeOrganizationId is load-bearing. requireOrganization() redirects when it is null and authenticated pages depend on it, so breaking that hook makes every user bounce or 404 immediately after signing in — and it reads like a routing bug, not an auth one.

Switching is handled by <OrganizationSwitcher />, which calls authClient.organization.setActive and refreshes.

Invitations

The organization plugin writes an invitation row; sendInvitationEmail is what makes it reachable. Without it the row exists and nobody is told, and the /accept-invitation/[id] page is unreachable by any normal flow — the email is the only thing that carries its URL.

Invitations expire after seven days: long enough to survive a holiday, short enough that a forwarded one does not stay live indefinitely. Re-inviting the same address cancels the pending one.

Both this and every other auth email need Resend configured — see transactional email.

Email verification

requireEmailVerification is on. Sign-in is refused until the address is confirmed.

Without it, sendOnSignUp is decorative: the verification email goes out and nothing checks whether it was ever acted on, so anyone can hold an account on an address they do not control — which is how invitation and password-reset flows get hijacked. Verification auto-signs the user in, so it costs one click.

It is unconditional, and that is newer than it looks: it used to be relaxed whenever no mail provider was configured, which is what every developer and every CI job without a Resend key got. That did not merely hide the "Confirm your email" screen — Better Auth signs a user in at sign-up, so an address nobody had proved they controlled got a working session.

What made it possible to switch on for everyone is that mail no longer needs a vendor: with no Resend key, packages/auth/lib/mail.ts delivers over SMTP to the Mailpit container, and throws only when nothing at all can deliver. The quickstart covers reading the link locally.

Rate limiting

Enabled by default: 100 requests per minute globally, with much tighter limits on the endpoints worth guessing at.

EndpointLimit
/sign-in/email5 per minute
/sign-up/email5 per minute
/request-password-reset3 per minute
/forget-password3 per minute
/reset-password5 per minute
/sign-in/magic-link3 per minute

Both names for the reset request are listed on purpose. The forgot-password form calls authClient.requestPasswordReset, so /request-password-reset is the path the app actually reaches; /forget-password is the deprecated alias Better Auth still serves. A rule naming only one of them is a limit with a way around it.

Counters live in Redis when REDIS_URL is set. Better Auth's built-in limiter is in-memory and production-only by default, which counts per replica — so scaling to two containers doubles the attempts an attacker gets, and a restart resets the count.

With no Redis it falls back to memory, which is the same degrade-rather-than- fail rule as everything else here: a single-container deployment still gets a limit, just a local one.

Redis also backs Better Auth's secondary storage when present, so sessions survive a rolling restart without a database round trip.

Sessions

Thirty-day expiry, refreshed at most once a day, with a five-minute signed cookie cache in front. getSession runs on every render of every authenticated page, so the cache turns most of those reads into a cookie decode rather than a query.

Cookies are HTTP-only and signed, and every protected route verifies server-side. The middleware check is optimistic — presence only, since validity cannot be established at the edge without a database round trip.

/api/auth must stay in the middleware's public routes. The app's matcher covers /(api|trpc)(.*), so treating the auth endpoints as protected redirects the sign-in request itself and authentication cannot complete. DEFAULT_PUBLIC_ROUTES in packages/auth/proxy.ts covers it.

Configuration

Auth is configured in packages/auth/lib/options.ts. That file is the single source of truth: both the running app and the schema generator read it, so the database cannot drift from the configuration.

After changing it:

bun run auth:generate   # regenerate the auth tables
bun run migrate         # apply them

Review the schema diff in full. The generator rewrites schema.prisma and does not restore hand-written directives — the @unique on stripeCustomerId, which the Stripe webhook depends on for a single indexed lookup, is the one that has been lost this way before.

Environment

VariableRequiredPurpose
BETTER_AUTH_SECRETyesSigns session cookies; at least 32 bytes
BETTER_AUTH_URLnoDefaults to NEXT_PUBLIC_APP_URL
AUTH_COOKIE_DOMAINnoSet to .example.com to share across subdomains
AUTH_TRUSTED_ORIGINSnoComma-separated extra origins for previews
GITHUB_CLIENT_ID / _SECRETnoEnables GitHub sign-in when both are set
GOOGLE_CLIENT_ID / _SECRETnoEnables Google sign-in when both are set

Generate a secret with bunx @better-auth/cli secret. It must be identical across every container that reads sessions, and different per environment.

Cookies on localhost are not scoped by port, so all three apps share them in development and misconfiguration is invisible. Test on real hostnames before shipping.

Where auth is mounted

apps/app owns the session and is the only app mounting the auth routes, at /api/auth/[...all]. The marketing site holds no session and links to the app to sign in; the API reads sessions directly from the database.

That keeps every browser auth request same-origin: no CORS, no preflight, no SameSite=None.

The browser client sets no baseURL, resolving against window.location.origin instead — so nothing environment-specific is baked into the bundle and one image serves every environment.