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
| 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.
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.
| 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:
bun run auth:generate # regenerate the auth tables
bun run migrate # apply themReview 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.