Lee Kong Si · 李氏总会

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 twoFactorRedirect and the app sends the user to /two-factor for 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, user and admin. 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

ActionClient call
Sign upauthClient.signUp.email({ email, password, name })
Sign inauthClient.signIn.email({ email, password })
Sign outauthClient.signOut()
Request password resetauthClient.requestPasswordReset({ email, redirectTo })
Complete password resetauthClient.resetPassword({ newPassword, token })
Resend verificationauthClient.sendVerificationEmail({ email })
Create organizationauthClient.organization.create({ name, slug })
Invite memberauthClient.organization.inviteMember({ email, role })
Set active organizationauthClient.organization.setActive({ organizationId })
List users (admin)authClient.admin.listUsers({ query })
Change passwordauthClient.changePassword({ currentPassword, newPassword, revokeOtherSessions })
Change emailauthClient.changeEmail({ newEmail, callbackURL })
List / revoke sessionsauthClient.listSessions(), authClient.revokeSession({ token })
Start two-factor setupauthClient.twoFactor.enable({ password })totpURI, backupCodes
Confirm a TOTP codeauthClient.twoFactor.verifyTotp({ code, trustDevice })
Delete accountauthClient.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.

On this page