Toast
ContributorDecisions

ADR-005: Authentication Architecture

ADR-005: Authentication Architecture

Status

Accepted

Context

Toast needs authentication for staff (admin users) to manage content, settings, and site configuration. The auth system must:

  1. Support multi-tenancy - Each site has its own staff users; the same email can exist on different sites
  2. Be extensible - Start with email+password, add OAuth/magic links later without architectural changes
  3. Integrate with our stack - Work well with Hono, Drizzle, PostgreSQL
  4. Provide good DX - Type safety, React hooks, minimal boilerplate

Requirements from PRD

"Auth: Better Auth" - Technology Choices table

"Staff/Member Experience: Staff users automatically get a linked member account" - Technical Wants

The PRD explicitly chose Better Auth. This ADR documents why that choice makes sense and how we implemented it.

Options Considered

1. Better Auth

Approach: Full-featured auth library with Drizzle adapter, built-in email+password and OAuth support.

Pros:

  • First-class Drizzle integration - uses our schema directly
  • TypeScript-native with excellent type inference
  • Supports our custom table names (staff instead of user)
  • Built-in session management with cookies
  • Extensible via plugins (magic link, OAuth providers)
  • Active development with modern architecture

Cons:

  • Relatively new library (less battle-tested than Auth.js)
  • Database hooks needed for multi-tenant schema customization
  • Documentation still maturing

2. Lucia

Approach: Minimalist auth library focused on session management.

Pros:

  • Very lightweight, no vendor lock-in
  • Full control over auth flow implementation
  • Simple mental model

Cons:

  • Deprecated (v3 is final version, author recommends alternatives)
  • Requires implementing email+password, OAuth, etc. from scratch
  • More code to maintain

3. Auth.js (NextAuth)

Approach: Popular auth library with provider ecosystem.

Pros:

  • Large ecosystem of OAuth providers
  • Battle-tested in production
  • Good documentation

Cons:

  • Primarily designed for Next.js - Hono adapter is community-maintained
  • "User" table structure doesn't map cleanly to our multi-tenant schema
  • Callback-heavy API is harder to reason about
  • Type inference is weaker than Better Auth

4. Custom Implementation

Approach: Build auth from scratch using password hashing + session cookies.

Pros:

  • Complete control
  • No external dependencies

Cons:

  • Security-critical code is risky to write ourselves
  • OAuth implementation would take significant time
  • Session management has many edge cases

Decision

Use Better Auth with a Drizzle adapter and custom schema mapping.

Rationale

  1. PRD alignment - Better Auth is the explicit technology choice in the PRD
  2. Drizzle integration - Native adapter means our schema is the source of truth
  3. Custom table names - Can map "user" to "staff" via schema configuration
  4. Multi-tenant extensibility - Database hooks allow injecting siteId into all auth records
  5. Type safety - auth.$Infer.Session provides typed user/session access
  6. Future-proof - OAuth and magic link plugins available when needed

Implementation Details

Why "Staff" Instead of "User"

The PRD distinguishes between two user types:

  • Staff - Admin users who create content, manage settings (authenticated via this system)
  • Members - Readers who subscribe, access gated content (future, separate auth flow)

Using staff makes the distinction explicit in code and prevents confusion. Better Auth's schema mapping (user: staff) handles the translation.

Multi-Tenant Site Scoping

Every auth table includes site_id for tenant isolation:

sites (tenant)
  └── staff (siteId FK, cascade delete)
        ├── session (siteId FK, userId FK)
        ├── account (siteId FK, userId FK)
        └── verification (siteId FK)

Email uniqueness is per-site: admin@example.com can exist on Site A and Site B as separate staff accounts. This is enforced by a composite unique constraint: UNIQUE(site_id, email).

Case-insensitive emails: The email column uses PostgreSQL's citext type, so Admin@Example.com and admin@example.com are treated as equal.

Database hooks propagate siteId: Better Auth doesn't know about multi-tenancy, so we use database hooks to copy siteId from staff to session/account/verification records:

databaseHooks: {
  session: {
    create: {
      before: async (session) => {
        const user = await db.select().from(staff).where(eq(staff.id, session.userId));
        return { data: { ...session, siteId: user[0].siteId } };
      },
    },
  },
  // Similar hooks for account and verification
}

Session Middleware Pattern

Authentication happens in two middleware layers:

  1. Session extraction (session()) - Runs on all routes, extracts user/session from cookies, sets context variables. Does NOT reject unauthenticated requests.

  2. Auth requirement (requireAuth()) - Applied to protected routes, returns 401 if no session exists.

// All routes get session context
app.use('*', session());

// Only protected routes require auth
contentRoutes.use('*', requireAuth());

This allows public endpoints (health checks, future public API) to coexist with protected ones.

Invite-Only Signup

Toast uses invite-only signup rather than open registration:

  1. First admin is created via seed script (pnpm db:seed)
  2. Additional staff are invited by existing admins (future feature)
  3. No public signup endpoint exposed

This matches Ghost's model where publications don't allow anonymous staff accounts.

Adding an OAuth Provider

Better Auth makes adding OAuth providers straightforward. Example for Google:

1. Configure the provider in apps/api/src/lib/auth.ts:

Better Auth supports Google OAuth but requires explicit configuration - it is not enabled by default. Add the google entry to the socialProviders object:

export const auth = betterAuth({
  // ... existing config
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
});

2. Add environment variables:

GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

3. Update the login UI to show a "Sign in with Google" button:

// In LoginForm.tsx
import { authClient } from '../lib/auth';

const handleGoogleSignIn = () => {
  authClient.signIn.social({ provider: 'google' });
};

4. Handle siteId for new OAuth users:

When a user signs in via OAuth for the first time, they don't have a siteId. You'll need to either:

  • Require site selection during OAuth callback
  • Use a default site for new OAuth users
  • Implement an invitation flow where the siteId is pre-determined

Future: SAML/SSO for Enterprise

Enterprise customers often require SAML-based single sign-on. The path to supporting this:

1. Architecture:

SAML operates differently from OAuth - the IdP initiates the flow. We'd need:

  • SP (Service Provider) metadata endpoint
  • ACS (Assertion Consumer Service) endpoint
  • Optional SLO (Single Logout) endpoint

2. Per-site SSO configuration:

Each site could configure their own IdP (Okta, Azure AD, etc.):

// Future: site_sso_config table
{
  siteId: uuid,
  provider: 'saml' | 'oidc',
  idpMetadataUrl: string,
  idpEntityId: string,
  // ... other SAML config
}

3. Implementation options:

  • Better Auth enterprise plugin - Check if/when SAML support is added
  • Dedicated SAML library - @node-saml/node-saml or saml2-js
  • Proxy to existing IdP - Use Ghost's existing SAML implementation if available

4. Considerations:

  • JIT (Just-In-Time) provisioning - auto-create staff on first SAML login
  • Attribute mapping - map IdP attributes to staff fields
  • Group/role mapping - enterprise often wants role sync from IdP
  • Site isolation - ensure SAML for Site A can't grant access to Site B

SAML is explicitly out of scope for the initial implementation but the multi-tenant architecture supports it.

Consequences

Positive

  • Type-safe auth context - c.get('user') returns properly typed AuthUser
  • Clean separation - Session middleware provides context; requireAuth enforces it
  • Multi-tenant from day one - No retrofitting site isolation later
  • Extensible - OAuth/magic link are configuration, not architecture changes
  • Audit-friendly - Database hooks enable auth event logging

Negative

  • Database hooks complexity - Multi-tenant siteId propagation requires hooks on every auth table
  • Cross-origin cookie challenges - Railway's preview subdomains (*.up.railway.app) are on the Public Suffix List (PSL), meaning browsers treat each subdomain as a separate "site" and prevent cross-subdomain cookie sharing. The primary solution is using a custom domain you control; SameSite=None; Secure is a fallback but faces increasing browser restrictions on third-party cookies (as of 2024-2025 browser guidance)
  • Newer library - Less community resources compared to Auth.js

Trade-offs Accepted

Better Auth's Drizzle integration and TypeScript-first design outweigh the maturity concerns. The hooks complexity is a one-time cost that enables proper multi-tenancy.

File Structure

apps/api/src/
├── lib/
│   └── auth.ts              # Better Auth instance, hooks, config
├── middleware/
│   ├── session.ts           # Session extraction middleware
│   └── require-auth.ts      # Auth requirement middleware
├── routes/
│   └── auth/
│       └── index.ts         # Auth routes (passthrough to Better Auth)
└── types/
    └── hono.ts              # Hono context type augmentation

apps/admin/src/
├── lib/
│   └── auth.ts              # Better Auth React client
└── components/
    └── LoginForm.tsx        # Login UI component

packages/db/src/
├── schema.ts                # staff, session, account, verification tables
└── seed.ts                  # Admin user seeding with scrypt password hashing

References

On this page