The template uses Better Auth 1.4 with the Prisma adapter and the admin
plugin. This document covers the config, roles, protecting routes, and sharing
sessions across the two apps.
- Database sessions — sessions are rows in Postgres, not stateless JWTs.
Revoking a session is a
DELETE— instant, no stale-token window. - No vendor cost — it costs whatever your Postgres costs; no per-MAU pricing.
- Code-first — the entire config is in
packages/auth/src/server.ts. No dashboard, no dev/prod drift.
import { prisma } from "@repo/db";
import { env } from "@repo/env/auth";
import { APP_NAME } from "@repo/env/client";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { admin } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
appName: APP_NAME, // from NEXT_PUBLIC_APP_NAME
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // no email provider wired by default
minPasswordLength: 8,
},
// Google is enabled only when both client id + secret are present.
socialProviders:
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } }
: undefined,
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh on activity
// cookieCache is intentionally OFF — see the note below.
},
rateLimit: { enabled: true, window: 60, max: 30 }, // brute-force protection
experimental: { joins: true }, // fuse session+user reads
plugins: [
admin({
defaultRole: "admin",
ac: accessControl, // from ./access-control — teaches the plugin our roles
roles: accessControlRoles, // { admin, editor, viewer }
adminRoles: ["admin"], // only `admin` gets the admin API
}),
nextCookies(), // must be LAST
],
});
cookieCacheis disabled. With it on, user fields (role,banned, …) snapshot into the cookie and stay stale for the cache TTL, so an admin banning a user would lag from that user's point of view. It's left off until admin mutations explicitly invalidate the cookie cache.
Env values come from @repo/env/auth (server) and @repo/env/client (the
APP_NAME display constant). Don't read process.env.* directly.
| Variable | Where | Purpose |
|---|---|---|
BETTER_AUTH_SECRET |
both apps | Cookie signing key. Must match across apps. 32+ chars |
BETTER_AUTH_URL |
admin (+ web if it gates) | Canonical URL of this app — used for OAuth callbacks |
DATABASE_URL |
both | Where sessions are stored |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
optional | Enables Google sign-in when both are set |
There are three roles: admin, editor, and viewer (the
app_role Postgres enum, mirrored as appRoleSchema in @repo/types).
| Role | Capabilities |
|---|---|
admin |
Full access — items, users, settings, audit log |
editor |
Read + create/update/delete items |
viewer |
Read-only (items) |
defaultRole is still admin so a fresh clone bootstraps with zero setup —
the first sign-up is an admin. Real projects should lower this to "viewer"
(or "editor") in packages/auth/src/server.ts and promote the first admin by
hand (see Bootstrapping the first admin).
Two layers, kept in lockstep:
- App-facing capabilities —
packages/auth/src/permissions.tsdefines aCapabilityunion (items.read,items.update,users.manage,audit.read, …) and aROLE_CAPABILITIESmap. Route/action guards check a capability, not a role name, so adding a role later is a data change here, not a sweep through call sites. Helpers:hasPermission(user, capability),hasRole(user, ...roles),hasAtLeastRole(user, minimumRole)(rank-based). - Better Auth access control —
packages/auth/src/access-control.tsmirrors the roles into Better Auth's own access-control system (createAccessControl), so the admin plugin API andauth.api.userHasPermission(...)understand them. Wired into the plugin viaac+roles.
Presentation labels live in @repo/types (ROLE_LABELS, ROLE_DESCRIPTIONS),
the single source of truth for role UI copy.
Just sign up at /sign-in → "Create one". To promote an existing account by
hand:
UPDATE "user" SET role = 'admin' WHERE email = 'you@example.com';After that, admins invite others through the /users page — invites always
create another admin (with a temporary password the inviter shares manually;
no email provider is wired by default).
requireAdmin() from @repo/auth/next is the source of truth. It reads the
session, then redirects to /sign-in (no session) or /forbidden (not an
admin, or banned and the ban hasn't expired).
For the non-admin roles, two sibling gates with the same redirect conventions:
requireRole(...roles)— admits the session only if its role is one of the listed ones, e.g.await requireRole("admin", "editor").requireCapability(capability)— resolves the role to its capability set and checks one capability, e.g.await requireCapability("items.update"). Prefer this for resource guards so role membership stays an implementation detail.
Both redirect to /sign-in when unauthenticated and /forbidden when the role
or capability isn't granted (or the user is banned), exactly like
requireAdmin().
import { requireAdmin } from "@repo/auth/next";
export default async function ItemsPage() {
const session = await requireAdmin();
// ↑ redirects to /sign-in if no session, /forbidden if not an admin
return <ItemsTable ownerId={session.user.id} />;
}"use server";
import { requireAdmin } from "@repo/auth/next";
export async function deleteItemAction(id: string) {
await requireAdmin();
// ... mutate via @repo/db, then invalidate the cache tag
}import { requireAdmin } from "@repo/auth/next";
export async function GET() {
await requireAdmin();
// ...
}requireAdmin() calls redirect() from next/navigation on failure, which
throws NEXT_REDIRECT — Next.js catches it and emits the redirect response.
Use Better Auth's React client, re-exported from @repo/auth/client:
"use client";
import { signIn, signOut, authClient } from "@repo/auth/client";
export function NavUser() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return null;
if (!session) return <a href="/sign-in">Sign in</a>;
return <button onClick={() => signOut()}>Sign out {session.user.name}</button>;
}proxy.ts in the admin app does an optimistic session-cookie check.
It's fast (no DB round-trip) but not a security boundary on its own — the
real check is requireAdmin() in the Server Component or action. It gates
everything except /sign-in, /forbidden, and /api/auth/*:
// apps/admin/proxy.ts (excerpt)
const PUBLIC_PATHS = new Set(["/sign-in", "/forbidden"]);
export async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
if (PUBLIC_PATHS.has(path) || path.startsWith("/_next") || path.startsWith("/api/auth")) {
return NextResponse.next();
}
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
if (path.startsWith("/api/"))
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const url = request.nextUrl.clone();
url.pathname = "/sign-in";
url.searchParams.set("redirect", path);
return NextResponse.redirect(url);
}
return NextResponse.next();
}The web app's proxy.ts does not gate auth — it applies a per-IP rate
limit to public POST endpoints. The web app is an anonymous read surface.
Why
proxy.tsand notmiddleware.ts? Next.js 16 renamedmiddleware.tstoproxy.tsafter CVE-2025-29927 (a header-trust vulnerability). Same API, different file name.
The two apps are on different origins (3000 / 3001) in development.
Only the admin app mounts the Better Auth handler at /api/auth/[...all] and is
where users sign in. Sessions live in the shared database. If you add an
authenticated surface to the web app, mount the handler there too and sign in
separately on each origin.
If both apps live under one parent domain (app.example.com,
admin.example.com), enable cross-subdomain cookies:
export const auth = betterAuth({
// ...
advanced: {
crossSubDomainCookies: { enabled: true, domain: ".example.com" }, // leading dot
},
});Then a sign-in on one subdomain authenticates the user on the other.
Add the plugin in packages/auth/src/server.ts, then regenerate and migrate:
import { magicLink } from "better-auth/plugins";
plugins: [
// ...
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({ to: email, subject: "Sign in", html: `<a href="${url}">Sign in</a>` });
},
}),
];pnpm auth:gen-schema # Better Auth — emits schema additions into schema.prisma
pnpm db:migrate:dev # author + apply the migration
pnpm db:generate # refresh the client typesAdmin user operations live in apps/admin/app/(dashboard)/users/_actions.ts and
call Better Auth's admin API:
inviteUserAction—auth.api.createUserwith a generated temp passwordbanUserAction/unbanUserAction—auth.api.banUser/unbanUser, thenrevokeUserSessionsso a ban takes effect immediatelyremoveUserAction— revoke sessions, thenauth.api.removeUserresetUserPasswordAction—setUserPassword+revokeUserSessions
Each guards with requireAdmin() first. The profile page
(/profile) lets a user revoke their own device sessions via
revokeMySessionAction.
nextCookies() must be last in the plugins array — Server Actions can't
write Set-Cookie synchronously without it.
BETTER_AUTH_SECRET doesn't match between the cookie and the server. Ensure
both apps share the same secret and that you restarted after editing
.env.local.
In Google Cloud Console, the authorized redirect URI must be
<BETTER_AUTH_URL>/api/auth/callback/google — e.g.
http://localhost:3001/api/auth/callback/google for local admin.
Check the user.role value in the database. If you just changed it, sign out
and back in (or delete the session row) to refresh.