Authentication
Better Auth in the API, shared sessions across subdomains, and how each app reads the current user.
Authentication is handled by Better Auth running inside
apps/api. The Next.js apps never hold the auth secret; they talk to the API's /api/auth/*
routes and read the resulting session cookie.
What is enabled
- Email and password sign-up and sign-in.
- Email verification is required. Unverified accounts cannot sign in; each attempt sends a fresh link. Opening the link verifies the address and signs the user in.
- Password reset by email. A reset signs out every other session.
- Account security in Settings: change password (optionally signing out other devices),
change email (the current address confirms first, then the new one is verified), list and
revoke active sessions, a JSON download of the user's personal data (
GET /api/me/export, no secrets), and account deletion confirmed by email. Organization owners must transfer ownership or delete the organization first; the API refuses before sending the email. - Two-factor authentication (TOTP) with one-time backup codes. Sign-in returns
twoFactorRedirectand the app sends the user to/two-factorfor the code; "Trust this device" skips the prompt for 30 days. - Rate limits stored in PostgreSQL (
rate_limit), so they hold across API instances. The default is 100 requests per minute per client IP and path, with tighter limits on sign-in, sign-up, password reset, verification, email change, deletion, and two-factor endpoints. - Admin plugin: two roles,
userandadmin. Admins can list users, change roles, ban, and impersonate. - Organization plugin: organizations, members, roles (
owner,admin,member), and email invitations. - Cross-subdomain cookies so one session works on every app of the project.
Server setup (@vigor/auth)
The API builds its instance once at boot:
import { createAuth } from "@vigor/auth";
import { createDb } from "@vigor/db";
import { createMailer } from "@vigor/email";
import { authBaseUrl, serverCookieDomain, serverEnv, serverHosts } from "@vigor/env/server";
const mailer = createMailer({
apiKey: serverEnv.RESEND_API_KEY,
from: serverEnv.EMAIL_FROM,
environment: serverEnv.APP_ENV,
});
export const auth = createAuth({
db,
baseURL: authBaseUrl,
appURL: serverHosts.app,
secret: serverEnv.BETTER_AUTH_SECRET,
trustedOrigins: Object.values(serverHosts),
cookieDomain: serverCookieDomain,
projectName: serverEnv.PROJECT_NAME,
sendEmail: mailer.send,
});auth.handler(request) is mounted on /api/auth/*. trustedOrigins, appURL, and
cookieDomain come from @vigor/env. Organization email links therefore point to the web
app's /accept-invitation?id=... route instead of the API host.
If staging sets BETTER_AUTH_URL, unset it during the hostname cutover so @vigor/env derives
the new API origin, or update it to https://api.<project>.cenvora.dev. A stale override keeps
authentication pointed at the old API host.
The Better Auth tables (user, session, account, verification, organization,
member, invitation, two_factor, rate_limit) are part of the Drizzle schema in @vigor/db and are created by the
normal migration flow.
The session cookie prefix is derived from the project name and full API host (including an
explicit local port), unless cookiePrefix is supplied. cookieDomain() resolves to
.<project>.cenvora.dev in staging, .<ROOT_DOMAIN> in production, and undefined in
development. The API passes that resolved value so a session spans the sibling services of its
project in staging or the production domain, while localhost uses a host-only cookie.
Trust every cookie sibling
A staging Domain=.<project>.cenvora.dev cookie reaches sibling services in that project but
not hosts under another project domain. This narrower delivery is not full security isolation:
all staging projects remain same-site under the shared registrable cenvora.dev domain, and
cookie-tossing risks remain. Trust every sibling service within a project. Production retains
the existing Domain=.<ROOT_DOMAIN> trust requirement, and mutually untrusted projects need
separate registrable domains.
Reading the session in a Next.js app
Server components and route handlers use the helper from @vigor/auth/next, which forwards
the incoming cookies to GET <api>/api/auth/get-session:
import { getServerSession } from "@vigor/auth/next";
import { publicHosts } from "@vigor/env/next";
import { redirect } from "next/navigation";
export default async function AccountPage() {
const session = await getServerSession(publicHosts.api);
if (!session) redirect("/sign-in");
return <p>Signed in as {session.user.email}</p>;
}getServerSession() returns null only when there is no cookie or Better Auth reports no
active session. Transport failures, non-success API responses, malformed JSON, and invalid
session shapes throw AuthSessionError, so an API outage is not misreported as a signed-out
user.
Client components use the React client from @vigor/auth/client:
"use client";
import { createAuthClient } from "@vigor/auth/client";
import { publicHosts } from "@vigor/env/next";
export const authClient = createAuthClient({ baseURL: publicHosts.api });
export function SignOutButton() {
return (
<button type="button" onClick={() => authClient.signOut()}>
Sign out
</button>
);
}The client is created with adminClient(), organizationClient(), and twoFactorClient(), so
authClient.admin.*, authClient.organization.*, and authClient.twoFactor.* are available
without extra setup.
Common calls
| Action | Client call |
|---|---|
| Sign up | authClient.signUp.email({ email, password, name }) |
| Sign in | authClient.signIn.email({ email, password }) |
| Sign out | authClient.signOut() |
| Request password reset | authClient.requestPasswordReset({ email, redirectTo }) |
| Complete password reset | authClient.resetPassword({ newPassword, token }) |
| Resend verification | authClient.sendVerificationEmail({ email }) |
| Create organization | authClient.organization.create({ name, slug }) |
| Invite member | authClient.organization.inviteMember({ email, role }) |
| Set active organization | authClient.organization.setActive({ organizationId }) |
| List users (admin) | authClient.admin.listUsers({ query }) |
| Change password | authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions }) |
| Change email | authClient.changeEmail({ newEmail, callbackURL }) |
| List / revoke sessions | authClient.listSessions(), authClient.revokeSession({ token }) |
| Start two-factor setup | authClient.twoFactor.enable({ password }) → totpURI, backupCodes |
| Confirm a TOTP code | authClient.twoFactor.verifyTotp({ code, trustDevice }) |
| Delete account | authClient.deleteUser({ callbackURL }) (sends a confirmation email) |
Protecting API routes
Inside apps/api, an AuthGuard calls auth.api.getSession({ headers }) on the incoming
request and attaches the session to the request object. Controllers that require a role check
session.user.role. Unauthenticated requests get a 401 with the ApiError shape from
@vigor/contracts.
Client IPs behind a proxy
Rate limits and session records use the client IP. Fastify resolves it from X-Forwarded-For
only when the connection comes from an address in TRUSTED_PROXIES (default: loopback,
link-local, and private ranges, which public clients cannot connect from). The API then passes the
result to Better Auth in x-vigor-client-ip, discarding any copy a client sent. After deploying,
check that new sessions record real client IPs; if they show the proxy's address, set
TRUSTED_PROXIES to the platform's proxy range.
Cookies in development
On localhost no cookie domain is set. Browsers share cookies across ports on localhost,
so a session created against http://localhost:4000 is visible to http://localhost:3000
and the other apps without any additional configuration. Use http://localhost, not
127.0.0.1, for every app so they stay in the same cookie jar.
Emails
Better Auth calls sendEmail for verification, password reset, and organization invitations.
Reusable templates include VerifyEmail, ResetPassword, OrganizationInvite,
ChangeEmail, DeleteAccount, and MagicLink. In development the console mailer prints each
link, so you can verify local accounts from the API log. Without a Resend key, console delivery is allowed only when
environment: "development" is passed to createMailer. Staging, test, and production fail
explicitly when RESEND_API_KEY is missing; they never log a message and pretend it was
delivered.