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:
- Support multi-tenancy - Each site has its own staff users; the same email can exist on different sites
- Be extensible - Start with email+password, add OAuth/magic links later without architectural changes
- Integrate with our stack - Work well with Hono, Drizzle, PostgreSQL
- 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 (
staffinstead ofuser) - 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
- PRD alignment - Better Auth is the explicit technology choice in the PRD
- Drizzle integration - Native adapter means our schema is the source of truth
- Custom table names - Can map "user" to "staff" via schema configuration
- Multi-tenant extensibility - Database hooks allow injecting
siteIdinto all auth records - Type safety -
auth.$Infer.Sessionprovides typed user/session access - 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:
-
Session extraction (
session()) - Runs on all routes, extracts user/session from cookies, sets context variables. Does NOT reject unauthenticated requests. -
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:
- First admin is created via seed script (
pnpm db:seed) - Additional staff are invited by existing admins (future feature)
- 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-secret3. 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-samlorsaml2-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 typedAuthUser - 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; Secureis 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 hashingReferences
- Better Auth Documentation
- Better Auth Drizzle Adapter
- Better Auth OAuth Providers
- PRD:
docs/decisions/000-product-requirements.md(Technology Choices table) - ADR-004: Service and Repository Architecture (layered architecture pattern)