---
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)