Roxabi Boilerplate
Architecture

Auth & Security Architecture

Better Auth integration, session management, guard system, OAuth, CORS/CSP hardening, and organization-scoped authentication.

Overview

Authentication and security form a layered defense across the Roxabi stack. The system is built on Better Auth — a library that handles session lifecycle, OAuth flows, email verification, and organization management — integrated into NestJS through a thin service wrapper and a global guard.

The architecture follows two core principles:

  • Deny by default. Every API endpoint requires authentication unless explicitly opted out with a decorator. There is no "open by default" surface.
  • Configuration over code. OAuth providers, email services, and security policies are driven by environment variables. Adding a new OAuth provider requires setting two env vars, not writing code.
Loading diagram...

For usage instructions (decorators, frontend hooks, environment setup), see the Authentication Guide.

Design Decisions

Why Better Auth over NextAuth / Lucia / custom?

Better Auth was chosen for three reasons:

  1. Framework-agnostic. It exposes a standard Request/Response handler, which maps cleanly onto Fastify via a thin adapter. No tight coupling to Next.js or any other meta-framework.
  2. Drizzle-native. The drizzleAdapter reads schema definitions directly, with usePlural: true matching the project's plural table naming convention. No ORM mismatch.
  3. Plugin system. Organization management and magic links are added as plugins rather than custom code. Each plugin extends both server routes and the client SDK. (The admin plugin was removed in Phase 1 (#268); admin actions now go through NestJS.)

Why a global guard instead of per-route middleware?

A global APP_GUARD ensures no endpoint is accidentally left unprotected. Developers must explicitly opt out with @AllowAnonymous() or @OptionalAuth(), which is safer than opt-in protection where a missing decorator means an open endpoint.

Cookie sessions with server-side validation provide near-immediate revocation — within the cookie cache window (5 minutes by default). Banning a user or rotating secrets takes effect once the cached cookie expires. JWT-based approaches require either short expiry (poor UX) or a revocation list (equivalent complexity). The cookie cache (maxAge: 5min) reduces database lookups without sacrificing revocation speed.

Auth Module Structure

The auth system is split into a pure configuration layer and a NestJS integration layer.

FileResponsibility
auth.instance.tsPure function createBetterAuth() that configures Better Auth. No NestJS imports. Testable in isolation.
auth.service.tsNestJS @Injectable() wrapper. Creates the Better Auth instance with injected dependencies (DB, email, config). Exposes handler() and getSession().
auth.guard.tsGlobal CanActivate guard. Reads decorator metadata, calls getSession(), enforces access rules.
auth.controller.tsCatch-all route @All('api/auth/*') that forwards raw Fastify requests to Better Auth's internal router.
auth.module.tsWires providers: AuthService, ResendEmailProvider, and registers AuthGuard as APP_GUARD.

The separation between auth.instance.ts and auth.service.ts is intentional. The pure function has no NestJS dependency, so it can be unit-tested with mock DB and email providers. The service layer handles DI wiring and event emission.

// auth.instance.ts — pure function, no NestJS
export function createBetterAuth(
  db: DrizzleDB,
  emailProvider: EmailProvider,
  config: AuthInstanceConfig,
  onOrganizationCreated?: OrganizationCreatedCallback
) {
  return betterAuth({
    basePath: '/api/auth',
    secret: config.secret,
    baseURL: config.baseURL,
    trustedOrigins: config.appURL ? [config.appURL] : [],
    database: drizzleAdapter(db, { provider: 'pg', usePlural: true }),
    // ... plugins, session config, social providers
  })
}

Session Lifecycle

Creation

Sessions are created when a user authenticates via email/password, OAuth, or magic link. Better Auth stores the session in the sessions table and sets an HTTP-only cookie on the response.

Validation

On every authenticated request, AuthGuard.canActivate() calls AuthService.getSession(), which:

  1. Converts Fastify headers to standard Headers using toFetchHeaders()
  2. Calls betterAuth.api.getSession({ headers }) — Better Auth reads the session cookie, validates it against the database (or cookie cache)
  3. If the session has an activeOrganizationId, resolves RBAC permissions via PermissionService.getPermissions()
  4. Returns the enriched session object: { user, session, permissions }

Refresh

Better Auth automatically refreshes sessions when updateAge (1 day) has passed since the last activity. The session lifetime (expiresIn) is 7 days. This means an active user's session is silently extended, while inactive sessions expire after 7 days.

To reduce database round-trips, session data is cached in the cookie itself for up to 5 minutes (cookieCache.maxAge). During this window, getSession() reads from the cookie without hitting PostgreSQL. After 5 minutes, the next request re-validates against the database.

SettingValueWhy
expiresIn7 daysBalance between security (short sessions) and UX (don't force re-login daily)
updateAge1 dayRefresh only once per day to minimize writes
cookieCache.maxAge5 minutesReduce DB lookups for rapid successive requests without stale data risk

Guard System

The AuthGuard is registered globally via APP_GUARD in AuthModule. It evaluates decorator metadata in a strict order.

Evaluation Order

Loading diagram...

Decorators

All decorators are thin SetMetadata wrappers. They set metadata keys that the guard reads via NestJS Reflector.

DecoratorMetadata KeyEffect
@AllowAnonymous()PUBLICSkip all auth checks
@OptionalAuth()OPTIONAL_AUTHAllow unauthenticated, attach session if present
@Roles('admin')ROLESRequire user's global role to match
@RequireOrg()REQUIRE_ORGRequire activeOrganizationId on the session
@Permissions('members:write')PERMISSIONSRequire RBAC permission(s); implies org context
@Session()(param decorator)Extract session from request.session
@SkipOrg()(interceptor metadata)Skip tenant context resolution in TenantInterceptor; used for endpoints that do not require org context

The @Permissions() decorator implicitly requires an active organization. If the session has no activeOrganizationId, the guard returns 403 before checking permissions.

OAuth Provider Setup

OAuth providers are conditionally enabled based on environment variables. The createBetterAuth() function checks for both client ID and secret; if either is missing, the provider is silently skipped.

const socialProviders: Record<string, unknown> = {}

if (config.googleClientId && config.googleClientSecret) {
  socialProviders.google = {
    clientId: config.googleClientId,
    clientSecret: config.googleClientSecret,
  }
}

if (config.githubClientId && config.githubClientSecret) {
  socialProviders.github = {
    clientId: config.githubClientId,
    clientSecret: config.githubClientSecret,
  }
}

The frontend discovers which providers are available at runtime by calling GET /api/auth/providers, which returns { google: boolean, github: boolean }. This endpoint is @AllowAnonymous() so login pages can conditionally render OAuth buttons.

Adding a New Provider

  1. Add the provider's client ID/secret to the env schema (apps/api/src/config/env.validation.ts)
  2. Add the conditional block in createBetterAuth() (apps/api/src/auth/auth.instance.ts)
  3. Update enabledProviders in AuthService (apps/api/src/auth/auth.service.ts)
  4. Update the frontend EnabledProviders type (apps/web/src/lib/authClient.ts)

No new routes or controllers are needed — Better Auth handles the OAuth callback flow at /api/auth/callback/{provider}.

Organization-Scoped Authentication

Organizations are first-class in the auth system via Better Auth's organization plugin.

activeOrganizationId

When a user switches organizations in the UI, Better Auth updates the activeOrganizationId column on their session row. This value flows through the entire request lifecycle:

  1. Session — Stored in sessions.active_organization_id
  2. Guard@RequireOrg() and @Permissions() check it
  3. RBACPermissionService.getPermissions(userId, orgId) resolves org-scoped permissions
  4. Tenant isolation — Downstream services use it to set the PostgreSQL app.tenant_id variable for RLS. SET LOCAL ROLE app_user must be called before set_config() in each transaction to properly enforce RLS. Without it, the config is set but RLS may not be enforced correctly if the connection is running as the schema owner.

Organization Creation Hook

When a new organization is created through Better Auth, the AuthService emits an ORGANIZATION_CREATED event via NestJS EventEmitter2. The RbacListener handles this event by:

  1. Seeding the four default roles (owner, admin, member, viewer) for the new organization
  2. Assigning the owner role to the creator's membership record
// auth.service.ts — emits event on org creation
this.auth = createBetterAuth(db, emailProvider, config,
  async ({ organizationId, creatorUserId }) => {
    await this.eventEmitter.emitAsync(
      ORGANIZATION_CREATED,
      new OrganizationCreatedEvent(organizationId, creatorUserId)
    )
  }
)

This event-driven approach decouples auth from RBAC — the auth module does not import role-seeding logic directly.

Email Verification Flow

Email/password authentication requires email verification (requireEmailVerification: true). The flow:

  1. User signs up via POST /api/auth/sign-up/email
  2. Better Auth creates the user with emailVerified: false and calls sendVerificationEmail()
  3. The EmailProvider (Resend by default) sends a verification link
  4. User clicks the link, which hits Better Auth's verification endpoint
  5. Better Auth sets emailVerified: true on the user record
  6. The user can now sign in

Email Provider Abstraction

Email sending is abstracted behind a Symbol-based injection token:

export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER')

export type EmailProvider = {
  send(params: { to: string; subject: string; html: string; text?: string }): Promise<void>
}

The default implementation (ResendEmailProvider) degrades gracefully:

EnvironmentRESEND_API_KEY set?Behavior
DevelopmentNoLogs email content to console
DevelopmentYesSends via Resend API
ProductionNoLogs error (emails silently fail)
ProductionYesSends via Resend API

Failed sends emit an EMAIL_SEND_FAILED event and throw an EmailSendException, allowing upstream code to handle the failure.

Production note: Since requireEmailVerification is enabled, a missing RESEND_API_KEY in production means users can register but never receive verification emails, effectively blocking sign-up. Treat RESEND_API_KEY as required in production.

CORS Configuration

CORS is configured in the bootstrap function (apps/api/src/index.ts) using a dedicated parser (apps/api/src/cors.ts).

How Origins are Resolved

The CORS_ORIGIN environment variable accepts a comma-separated list of origins. The parseCorsOrigins() function handles edge cases:

InputProduction?Result
http://localhost:3000NoAllow that origin
http://a.com,http://b.comNoAllow both origins
*NoAllow all (wildcard)
*YesNo CORS headers — returns false, logs warning. Cross-origin browser requests will fail but same-origin requests are unaffected.
*,https://app.example.comYesStrip wildcard, allow https://app.example.com

Credentials are always enabled (credentials: true) because authentication relies on cookies.

In development, the frontend (port 3000) and API (port 4000) run on different ports. Cross-port cookies would require SameSite=None and HTTPS, which is impractical for local development. Instead, the frontend uses a Nitro dev proxy:

// apps/web/vite.config.ts
nitro({
  config: {
    devProxy: {
      '/api/**': { target: apiTarget, changeOrigin: true },
    },
    routeRules: {
      '/api/**': { proxy: `${apiTarget}/api/**` },
    },
  },
})

The browser sends requests to localhost:3000/api/*, which Nitro proxies to the API. Cookies are set on localhost:3000 (same origin as the frontend), avoiding cross-origin cookie issues entirely.

In production, both apps deploy to Vercel on the same domain, so cookies work natively without proxying.

Security Headers (CSP, HSTS, and More)

Security headers are applied globally via @fastify/helmet, registered before any routes in the bootstrap function.

Content Security Policy

contentSecurityPolicy: {
  directives: {
    defaultSrc: ["'none'"],
    scriptSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
    styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
    imgSrc: ["'self'", 'data:'],
    fontSrc: ["'self'"],
    connectSrc: ["'self'"],
  },
}

The CSP starts restrictive (defaultSrc: 'none') and opens specific directives. The unpkg.com allowlist supports Swagger UI assets. Note that both 'unsafe-inline' and the unpkg.com allowlist apply in all environments including production. For production hardening, consider replacing 'unsafe-inline' with nonce-based CSP and removing unpkg.com from scriptSrc by self-hosting Swagger UI assets.

Other Headers

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS for 1 year
X-Frame-OptionsDENYPrevent clickjacking
X-Content-Type-OptionsnosniffPrevent MIME sniffing
Referrer-Policyno-referrerDon't leak referrer information
Permissions-Policycamera=(), microphone=(), geolocation=()Disable unused browser APIs
Cross-Origin-Embedder-Policy(disabled)Allow cross-origin resources (fonts, images)

The Permissions-Policy header is set via a custom Fastify onSend hook because Helmet v8 does not include it natively.

Rate Limiting

Rate limiting is implemented via @nestjs/throttler with two tiers, configured in ThrottlerConfigModule.

TierTTLLimitBlock DurationPurpose
global60s60 req(none)General API abuse prevention
auth60s5 req5 minBrute-force login protection

All values shown are defaults. TTL, limit, and block duration are configurable via environment variables (RATE_LIMIT_GLOBAL_TTL, RATE_LIMIT_AUTH_TTL, RATE_LIMIT_AUTH_BLOCK_DURATION).

Storage backend selection:

EnvironmentUpstash configured?Storage
DevelopmentNoIn-memory (single process)
ProductionRequiredUpstash Redis (distributed)

The env validation (apps/api/src/config/env.validation.ts) enforces that KV_REST_API_URL and KV_REST_API_TOKEN are set in production, preventing accidental deployment with in-memory rate limiting that would not work across serverless invocations.

Frontend Auth Client

The frontend uses Better Auth's React SDK, configured in 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 } = authClient

Key Design Choices

  • baseURL uses window.location.origin in the browser, so requests go through the Nitro proxy in development and hit the same domain in production. Server-side rendering falls back to http://localhost:3000.
  • Plugins mirror the serverorganizationClient() and magicLinkClient() match the server-side organization() and magicLink() plugins. Mismatched plugins cause runtime errors. The admin() plugin was removed in Phase 1 (#268); admin actions now go through the NestJS AdminModule.
  • Provider discoveryfetchEnabledProviders() calls GET /api/auth/providers to discover which OAuth buttons to render, rather than hardcoding them.

Frontend Permission Helpers

Permission checks on the frontend use utility functions in apps/web/src/lib/permissions.ts:

import { hasPermission, hasAllPermissions, hasAnyPermission } from '@/lib/permissions'

// Single permission check
const canInvite = hasPermission(session, 'members:write')

// Multiple permissions (AND)
const canManageRoles = hasAllPermissions(session, ['roles:write', 'roles:delete'])

// Multiple permissions (OR)
const canViewMembers = hasAnyPermission(session, ['members:read', 'members:write'])

These functions read from the permissions array that AuthService.getSession() attaches to the session object.

Note: Route guards (requireAuth, requireGuest, enforceRoutePermission) no longer call authClient.getSession() directly. The root route fetches the session once via getServerEnrichedSession() and passes it through TanStack Router's context. Guards read ctx.context.session — no per-route fetch occurs. See the Navigation Guards guide for details.

Environment Variable Reference

VariableRequiredDefaultPurpose
BETTER_AUTH_SECRETYes(dev default)Session signing secret. Must be 32+ chars in production.
BETTER_AUTH_URLNohttp://localhost:4000Base URL for Better Auth (used for OAuth callbacks)
APP_URLNohttp://localhost:3000Frontend URL, added to trustedOrigins
CORS_ORIGINNohttp://localhost:3000Allowed CORS origins (comma-separated)
GOOGLE_CLIENT_IDNo(none)Google OAuth — provider enabled when both ID and secret are set
GOOGLE_CLIENT_SECRETNo(none)Google OAuth secret
GITHUB_CLIENT_IDNo(none)GitHub OAuth — provider enabled when both ID and secret are set
GITHUB_CLIENT_SECRETNo(none)GitHub OAuth secret
RESEND_API_KEYNo(none)Resend email service. Required for email verification in production.
EMAIL_FROMNonoreply@yourdomain.comSender address for transactional emails

The env validation schema (apps/api/src/config/env.validation.ts) enforces that BETTER_AUTH_SECRET is not a known insecure default in production.

File Reference

FilePath
Auth instance (pure)apps/api/src/auth/auth.instance.ts
Auth service (NestJS)apps/api/src/auth/auth.service.ts
Auth guardapps/api/src/auth/auth.guard.ts
Auth controllerapps/api/src/auth/auth.controller.ts
Auth moduleapps/api/src/auth/auth.module.ts
Fastify header conversionapps/api/src/auth/fastifyHeaders.ts
Decoratorsapps/api/src/auth/decorators/
Email provider interfaceapps/api/src/auth/email/email.provider.ts
Resend implementationapps/api/src/auth/email/resend.provider.ts
Auth schema (tables)apps/api/src/database/schema/auth.schema.ts
RBAC schemaapps/api/src/database/schema/rbac.schema.ts
Permission serviceapps/api/src/rbac/permission.service.ts
CORS parserapps/api/src/cors.ts
Bootstrap (headers, CORS)apps/api/src/index.ts
Env validationapps/api/src/config/env.validation.ts
Frontend auth clientapps/web/src/lib/authClient.ts
Frontend permissionsapps/web/src/lib/permissions.ts
Vite proxy configapps/web/vite.config.ts

We use cookies to improve your experience. You can accept all, reject all, or customize your preferences.