Authentication Guide
Better Auth setup, guard system, decorators, session management, and frontend integration.
Overview
Authentication is powered by Better Auth with the Drizzle adapter. It supports:
- Email + password with email verification
- OAuth providers (Google, GitHub) — conditionally enabled when credentials are configured
- Magic link sign-in via email
- Organizations with role-based membership and invitations
- Admin actions via NestJS AdminModule (Better Auth admin plugin removed in Phase 1 #268)
The auth system follows a deny-by-default model: every endpoint requires authentication unless explicitly marked otherwise.
Architecture
Auth is split into two layers:
| File | Role | Description |
|---|---|---|
auth.instance.ts | Configuration | Pure function createBetterAuth() that configures Better Auth with the Drizzle adapter, plugins, email provider, and OAuth settings. No NestJS dependency. |
auth.service.ts | NestJS wrapper | Injectable service that creates the Better Auth instance and exposes getSession() for the guard. Injects DRIZZLE, EMAIL_PROVIDER, and ConfigService. |
The AuthModule wires everything together:
@Module({
controllers: [AuthController],
providers: [
AuthService,
{ provide: EMAIL_PROVIDER, useClass: ResendEmailProvider },
{ provide: APP_GUARD, useClass: AuthGuard },
],
exports: [AuthService],
})
export class AuthModule {}The AuthController mounts Better Auth's HTTP handler at /api/auth/* as a catch-all route, forwarding raw Fastify requests to Better Auth's internal router.
Auth Guard & Decorators
The AuthGuard is registered as a global guard via APP_GUARD. Every route is protected by default. Use decorators to modify this behavior:
@AllowAnonymous()
Skip authentication entirely. The endpoint is open to everyone.
@AllowAnonymous()
@Get('health')
healthCheck() {
return { status: 'ok' }
}@OptionalAuth()
Allow unauthenticated requests but still attach the session if a valid one exists. Useful for endpoints that behave differently for logged-in users.
@OptionalAuth()
@Get('feed')
getFeed(@Session() session: AuthSession | null) {
// session is null for anonymous users
}@Roles(...roles)
Require the authenticated user to have one of the specified roles. Returns 403 Forbidden if the user's role doesn't match.
@Roles('admin')
@Delete('users/:id')
deleteUser(@Param('id') id: string) { ... }@RequireOrg()
Require the session to have an active organization. Returns 403 Forbidden with message "No active organization" if not set.
@RequireOrg()
@Get('org/settings')
getOrgSettings(@Session() session: AuthSession) { ... }@Session()
Parameter decorator that extracts the session object from the request. The session contains:
type AuthSession = {
user: { id: string; role?: string }
session: { id: string; activeOrganizationId?: string | null }
}Guard evaluation order
The guard checks metadata in this order:
PUBLIC— if set, allow immediately- Fetch session from Better Auth
OPTIONAL_AUTH— if set and no session, allow- No session →
401 Unauthorized4b. Soft-deleted account →403 Forbidden ROLES— check user role →403 Forbiddenif mismatchREQUIRE_ORG— check active org →403 Forbiddenif missingPERMISSIONS— check tenant-scoped permissions →403 Forbiddenif insufficient
Session Management
Better Auth uses cookie-based sessions:
| Setting | Value | Description |
|---|---|---|
expiresIn | 7 days | Session lifetime |
updateAge | 1 day | Refresh the session after this much inactivity |
cookieCache.enabled | true | Cache session in cookie to reduce DB lookups |
cookieCache.maxAge | 5 minutes | Cookie cache duration |
Same-origin cookies
The frontend (port 3000) and API (port 4000) run on different ports in development. To keep cookies on the same origin (avoiding SameSite=None), the frontend uses a Nitro dev proxy:
/api/* → http://localhost:4000This means the browser sends requests to localhost:3000/api/auth/*, which are proxied to the API. Cookies are set on localhost:3000 — same origin as the frontend.
In production, both apps deploy to Vercel on the same domain, so cookies work natively.
Email Provider
Email sending is abstracted behind an injection token:
export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER')
export interface EmailProvider {
send(options: { to: string; subject: string; html: string }): Promise<void>
}The default implementation is ResendEmailProvider, which uses the Resend SDK. It is registered in AuthModule:
{ provide: EMAIL_PROVIDER, useClass: ResendEmailProvider }Better Auth invokes the email provider for three flows:
| Flow | Trigger | Template |
|---|---|---|
| Email verification | User signs up | "Click here to verify your email" |
| Password reset | User requests reset | "Click here to reset your password" |
| Magic link | User requests magic link | "Click here to sign in" |
To swap email providers, create a new class implementing EmailProvider and register it in the module.
Frontend Client
The frontend uses Better Auth's React client SDK:
// apps/web/src/lib/authClient.ts
import { magicLinkClient, organizationClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000',
// Better Auth admin client plugin removed in Phase 1 (#268)
// All admin actions go through NestJS AdminModule with guards + audit logging
plugins: [organizationClient(), magicLinkClient()],
})
export const { useSession, signIn, signUp, signOut } = authClientKey points:
baseURLuseswindow.location.originin the browser, so requests go through the Vite proxy- Server-side falls back to
http://localhost:3000 - Plugins must mirror the server-side plugins (organization, magic link)
- Admin actions go through NestJS AdminModule (not Better Auth's admin client plugin)
Usage in components
import { useSession, signOut } from '@/lib/authClient'
function UserMenu() {
const { data: session } = useSession()
if (!session) return null
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign out</button>
</div>
)
}Environment Variables
| Variable | Required | Description |
|---|---|---|
BETTER_AUTH_SECRET | Yes | Secret used for signing sessions and tokens. Must be a random string of 32+ characters. In production, the app throws on startup if this is set to a known default value. |
GOOGLE_CLIENT_ID | No | Google OAuth client ID. Google sign-in is only enabled when both ID and secret are set. |
GOOGLE_CLIENT_SECRET | No | Google OAuth client secret. |
GITHUB_CLIENT_ID | No | GitHub OAuth client ID. GitHub sign-in is only enabled when both ID and secret are set. |
GITHUB_CLIENT_SECRET | No | GitHub OAuth client secret. |
RESEND_API_KEY | No | API key for the Resend email service. Required for email verification, password reset, and magic link flows. |
OAuth providers are conditionally enabled: if the client ID and secret are both set, the provider is registered. If either is missing, the provider is silently skipped.