--- title: Authentication description: Self-hosted authentication with organizations, powered by Better Auth. type: reference related: - /en/docs/packages/database - /en/docs/packages/email --- # Authentication kreogen uses [Better Auth](https://better-auth.com) 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 | Import | Runtime | Use for | | ----------------------------- | ------- | ----------------------------------------------------- | | `@kreogen/auth/session` | server | `auth()`, `requireSession()`, `requireOrganization()` | | `@kreogen/auth/client` | browser | `authClient`, `useSession`, `signIn`, `signOut` | | `@kreogen/auth/server` | server | The Better Auth instance, for `auth.api.*` | | `@kreogen/auth/proxy` | edge | `authMiddleware`, `DEFAULT_PUBLIC_ROUTES` | | `@kreogen/auth/handlers` | server | The route handlers, mounted by `apps/app` only | | `@kreogen/auth/organizations` | server | Member listing and search | | `@kreogen/auth/components/*` | browser | Sign-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. ```ts 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: ```tsx 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](/en/docs/packages/database#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 ``, 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](/en/docs/packages/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](/en/docs/setup/quickstart#create-an-account) 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. | Endpoint | Limit | | ------------------------- | ------------ | | `/sign-in/email` | 5 per minute | | `/sign-up/email` | 5 per minute | | `/request-password-reset` | 3 per minute | | `/forget-password` | 3 per minute | | `/reset-password` | 5 per minute | | `/sign-in/magic-link` | 3 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: ```sh 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 | Variable | Required | Purpose | | ------------------------------ | -------- | ------------------------------------------------ | | `BETTER_AUTH_SECRET` | yes | Signs session cookies; at least 32 bytes | | `BETTER_AUTH_URL` | no | Defaults to `NEXT_PUBLIC_APP_URL` | | `AUTH_COOKIE_DOMAIN` | no | Set to `.example.com` to share across subdomains | | `AUTH_TRUSTED_ORIGINS` | no | Comma-separated extra origins for previews | | `GITHUB_CLIENT_ID` / `_SECRET` | no | Enables GitHub sign-in when both are set | | `GOOGLE_CLIENT_ID` / `_SECRET` | no | Enables 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. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)