Better Auth + OIDC Provider Architecture for Moklabs Multi-App SSO
Better Auth + OIDC Provider Architecture for Moklabs Multi-App SSO
Research date: 2026-03-19 | Agent: Deep Research | Confidence: High
Executive Summary
- Better Auth v1.5.5 (stable, March 2026) is viable for a central auth server at auth.moklabs.io using the new OAuth 2.1 Provider plugin (replaces deprecated OIDC Provider plugin)
- Multi-app SSO works via standard OAuth 2.1 Authorization Code Flow with PKCE — each Moklabs project registers as a trusted client, users authenticate once at auth.moklabs.io and get seamless login across all apps
- Social login (Google, GitHub) integrates natively on the central auth server; consuming apps inherit it via OIDC flow — no per-app provider configuration needed
- Drizzle ORM adapter has full PostgreSQL support with built-in migration CLI; schema includes user, session, account, verification tables plus OAuth provider tables
- Zitadel is a viable fallback but adds significant operational complexity (Go binary + Next.js login UI + PostgreSQL, needs 4 CPU cores for production) vs Better Auth’s lightweight library approach
- Recommendation: Proceed with Better Auth + OAuth 2.1 Provider — lighter weight, TypeScript-native, MIT licensed, active development, and sufficient for Moklabs’ 8+ app SSO needs
Proposed Architecture
┌─────────────────────────────────┐
│ auth.moklabs.io │
│ Hono + Better Auth v1.5 │
│ OAuth 2.1 Provider Plugin │
│ PostgreSQL (shared auth DB) │
│ │
│ Social: Google, GitHub │
│ Passkeys, Email/Password │
└────────────┬─────────────────────┘
│
OIDC Authorization Code Flow + PKCE
│
┌───────────┬───────────┼───────────┬───────────┐
│ │ │ │ │
┌────┴────┐ ┌────┴────┐ ┌───┴─────┐ ┌──┴──────┐ ┌──┴──────┐
│ App 1 │ │ App 2 │ │ App 3 │ │ App N │ │ Agents │
│ Generic │ │ Generic │ │ Generic │ │ Generic │ │ Client │
│ OAuth │ │ OAuth │ │ OAuth │ │ OAuth │ │ Creds │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
1. OAuth 2.1 Provider Plugin Validation
Plugin Status
| Aspect | Status | Details |
|---|---|---|
| Current version | v1.5.5 (stable) | Released March 15, 2026 — 600+ commits, 70 new features in v1.5 series |
| Plugin name | @better-auth/oauth-provider | New — replaces deprecated oidc-provider plugin |
| OIDC compatibility | Yes | Supports openid, profile, email scopes, UserInfo endpoint, ID tokens |
| License | MIT | More permissive than Zitadel’s AGPL-3.0 |
| MCP support | Built-in | OAuth protected resource metadata at /.well-known/oauth-protected-resource |
Supported Grant Types
| Grant | Use Case | PKCE Required |
|---|---|---|
| Authorization Code | User-facing apps (web, mobile, SPA) | Yes (S256 only, no plain) |
| Refresh Token | Token renewal via offline_access scope | N/A |
| Client Credentials | Machine-to-machine (agents, services) | No |
Key Endpoints
GET /.well-known/openid-configuration— OIDC discoveryGET /oauth2/authorize— Authorization endpointPOST /oauth2/token— Token exchangeGET /oauth2/userinfo— User info (OIDC)GET /.well-known/jwks.json— JWKS for token verificationPOST /oauth2/register— Dynamic client registration (optional)GET /.well-known/oauth-protected-resource— MCP metadata
Verdict: PASS
The OAuth 2.1 Provider plugin supports all requirements for multi-app SSO. It’s the recommended path forward (OIDC Provider plugin is being deprecated).
2. Multi-App SSO with Session Sharing
How It Works
- User visits
app1.moklabs.io→ not authenticated - App1 redirects to
auth.moklabs.io/oauth2/authorizewith PKCE challenge - User logs in at auth.moklabs.io (or is already logged in via session cookie)
- Auth server redirects back with authorization code
- App1 exchanges code for access token + ID token + refresh token
- User visits
app2.moklabs.io→ redirects to auth.moklabs.io → already logged in → instant redirect back with code → seamless SSO
Trusted Client Configuration
Each Moklabs app registers as a trusted client with skipConsent: true:
// auth.moklabs.io server config
import { betterAuth } from "better-auth";
import { oAuthProvider } from "@better-auth/oauth-provider";
export const auth = betterAuth({
database: { /* PostgreSQL via Drizzle */ },
plugins: [
oAuthProvider({
trustedClients: [
{
clientId: "paperclip-app",
clientSecret: process.env.PAPERCLIP_CLIENT_SECRET,
redirectURIs: ["https://paperclip.moklabs.io/api/auth/callback"],
skipConsent: true,
enableEndSession: true,
},
{
clientId: "jarvis-app",
clientSecret: process.env.JARVIS_CLIENT_SECRET,
redirectURIs: ["https://jarvis.moklabs.io/api/auth/callback"],
skipConsent: true,
enableEndSession: true,
},
// ... repeat for each Moklabs app
],
}),
],
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
},
});
Consuming App Configuration
Each app uses Better Auth’s Generic OAuth plugin:
// app1.moklabs.io
import { betterAuth } from "better-auth";
import { genericOAuth } from "@better-auth/plugins";
export const auth = betterAuth({
database: { /* app-specific DB or shared */ },
plugins: [
genericOAuth({
config: [{
providerId: "moklabs",
discoveryUrl: "https://auth.moklabs.io/.well-known/openid-configuration",
clientId: "paperclip-app",
clientSecret: process.env.AUTH_CLIENT_SECRET,
scopes: ["openid", "profile", "email", "offline_access"],
pkce: true,
}],
}),
],
});
Cross-Domain Session Strategy
| Scenario | Approach |
|---|---|
Same root domain (*.moklabs.io) | Cross-subdomain cookies: crossSubDomainCookies: { enabled: true, domain: "moklabs.io" } on auth server |
| Different domains | Standard OIDC redirect flow — no cookie sharing needed |
| Silent re-auth | Use prompt=none in authorize request via hidden iframe or background fetch |
| Token refresh | Each app manages its own refresh token via offline_access scope |
Scalability: 8+ OIDC Clients
There is no documented limit on the number of trusted clients. The trustedClients array is static configuration — performance scales linearly and avoids database lookups entirely. Dynamic client registration is also available if needed for future third-party integrations.
Verdict: PASS
Multi-app SSO works as expected. Trusted clients skip consent for first-party apps. Cross-subdomain cookies provide session sharing within *.moklabs.io.
3. Token Refresh Flow Across Apps
Access Token Configuration
| Parameter | Default | Recommended |
|---|---|---|
| Access token expiry | 1 hour | 1 hour (keep default) |
| Refresh token expiry | 30 days | 30 days (keep default) |
| Refresh token rotation | Yes (new token per refresh) | Yes (keep default) |
| PKCE requirement | Mandatory for public clients | Mandatory for all |
Silent Re-Auth Flow
For SPAs and apps that need background token refresh:
- Primary method: Use refresh tokens via
offline_accessscope — each app independently refreshes its own tokens - Fallback: Hidden iframe with
prompt=noneto auth.moklabs.io — works if user has active session on auth server - Caveat: Third-party cookie restrictions in Safari/Firefox may block iframe approach — refresh tokens are the reliable path
Verdict: PASS
Refresh token rotation is built-in and secure. Each app manages its own token lifecycle independently.
4. Social Login Integration
Configuration
Social providers are configured only on the central auth server. Consuming apps inherit social login capabilities automatically through the OIDC flow.
| Provider | Status | Notes |
|---|---|---|
| Built-in | clientId + clientSecret in auth server config | |
| GitHub | Built-in | clientId + clientSecret in auth server config |
| Discord | Built-in | Available if needed |
| Apple | Built-in | Available if needed |
Account Linking
Better Auth automatically links accounts when a user signs in with a social provider and an account with the same email already exists. This is the desired behavior for Moklabs SSO.
Verdict: PASS
Social login is centralized and transparent to consuming apps.
5. Drizzle ORM Adapter Compatibility
Setup
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
export const auth = betterAuth({
database: drizzleAdapter(drizzle(pool), {
provider: "pg",
schema: authSchema, // Drizzle schema definition
}),
});
Core Schema Tables
| Table | Purpose |
|---|---|
user | User profiles (id, name, email, emailVerified, image, createdAt, updatedAt) |
session | Active sessions (id, userId, token, expiresAt, ipAddress, userAgent) |
account | Auth provider links (id, userId, providerId, accountId, accessToken, refreshToken) |
verification | Email verification, password reset tokens |
OAuth Provider Additional Tables
| Table | Purpose |
|---|---|
oauthApplication | Registered OAuth clients (clientId, clientSecret, redirectURIs) |
oauthAccessToken | Issued access/refresh tokens |
oauthConsent | User consent records |
Migration Path
# Generate Drizzle schema from Better Auth config
npx better-auth generate --output ./src/db/auth-schema.ts
# Or generate SQL migration
npx better-auth migrate
# For non-default PostgreSQL schema (e.g., "auth")
# Append to connection URI: ?options=-c search_path=auth
The CLI auto-detects your search_path and creates tables in the correct PostgreSQL schema.
Migration from Supabase Auth
Better Auth has a documented migration guide from Supabase Auth. Key steps:
- Export users from Supabase
- Map Supabase user fields to Better Auth schema
- Import accounts with provider mappings
- Update client-side auth calls
Verdict: PASS
Drizzle adapter is mature and fully supports PostgreSQL. Migration CLI handles schema generation.
6. Organization / Multi-Tenancy Plugin
Current Capabilities
| Feature | Status |
|---|---|
| Multi-organization per user | Yes |
| Built-in roles (owner, admin, member) | Yes |
| Custom roles | Yes |
| Member management (invite, remove) | Yes |
| Organization switching | Yes |
| Fine-grained permissions | Yes |
| Email-based invitations with expiration | Yes |
| Org limit per user | Configurable |
| Member limit per org | Configurable |
Limitations for Moklabs
| Limitation | Impact | Mitigation |
|---|---|---|
| Organization data is per-app database | If using shared auth DB, orgs are shared; if per-app, orgs are isolated | Use shared auth DB for org data |
| No admin-level access to all orgs without membership | Admin panel needs custom logic | Build admin override in auth server |
| Per-tenant provider config only for SSO providers | Core providers (email/pass) are global | Acceptable for Moklabs (same providers everywhere) |
Future B2B Readiness
The organization plugin provides a solid foundation for future B2B features (customer workspaces, team management). It can be added to the central auth server and consumed via OIDC claims.
Verdict: PASS (with caveats)
Organization plugin is functional for B2B multi-tenancy. Some custom work needed for admin cross-org access.
7. Zitadel Comparison (Fallback Option)
| Dimension | Better Auth + OAuth 2.1 Provider | Zitadel |
|---|---|---|
| Architecture | TypeScript library embedded in Hono app | Go binary + Next.js login UI (2 containers) |
| Database | PostgreSQL (or any supported DB) | PostgreSQL 17 only (CockroachDB dropped) |
| Resource needs | Minimal (same as host app) | 4 CPU cores + 16GB RAM (production HA) |
| OIDC compliance | OAuth 2.1 with OIDC compat | Full OIDC/SAML/SCIM/LDAP |
| Multi-tenancy | Plugin-based (org plugin) | Built-in with strict hierarchy |
| Setup complexity | npm install + config | Docker Compose + Traefik + DB setup |
| License | MIT | AGPL-3.0 |
| Audit trail | Basic (via DB) | Event-sourced (every mutation is an event) |
| UI | Build your own | Built-in login UI |
| MCP support | Built-in | None |
| Community | 26,304 GitHub stars, 2 years old | 13,018 GitHub stars, 6 years old |
| Maturity | v1.5.5, active development | v4.x, stable but operationally complex |
When to Consider Zitadel
- If you need SAML/SCIM/LDAP support for enterprise customers
- If you need event-sourced audit trail for compliance
- If you need built-in login UI without frontend development
- If you need identity brokering with per-org IdP configuration
When Better Auth is Better
- For TypeScript-native projects (Moklabs’ entire stack)
- For lightweight deployment (library, not a separate service)
- For rapid iteration (same language, same tooling)
- For MCP/agent auth (built-in OAuth protected resource metadata)
- For cost efficiency (no additional infrastructure)
Verdict: Better Auth is the right choice for Moklabs
Zitadel is overkill for current needs. Better Auth provides everything needed with significantly less operational overhead.
Recommended Auth Schema
-- Core Better Auth tables (auto-generated)
CREATE TABLE "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN DEFAULT FALSE,
image TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user"(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE account (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user"(id),
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMP,
scope TEXT,
id_token TEXT,
password TEXT, -- hashed, for email/password provider
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- OAuth 2.1 Provider tables (added by plugin)
CREATE TABLE oauth_application (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT, -- hashed by default
name TEXT,
redirect_uris TEXT[], -- array of allowed redirect URIs
type TEXT DEFAULT 'confidential', -- 'public' or 'confidential'
disabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE oauth_access_token (
id TEXT PRIMARY KEY,
token TEXT NOT NULL,
client_id TEXT NOT NULL,
user_id TEXT,
scopes TEXT,
expires_at TIMESTAMP NOT NULL,
refresh_token TEXT,
refresh_token_expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE oauth_consent (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
scopes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Organization plugin tables (if enabled)
CREATE TABLE organization (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE,
logo TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE member (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id),
user_id TEXT NOT NULL REFERENCES "user"(id),
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE invitation (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id),
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
status TEXT DEFAULT 'pending',
expires_at TIMESTAMP NOT NULL,
inviter_id TEXT REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT NOW()
);
Note: This schema is illustrative. Use
npx better-auth generateto produce the exact Drizzle schema for your configuration. Column names may use camelCase in Drizzle.
Migration Plan (Per-Project Steps)
Phase 1: Central Auth Server (Week 1)
- Create
auth.moklabs.ioproject — Hono + Better Auth + OAuth 2.1 Provider - Set up PostgreSQL — Shared auth database with Drizzle ORM
- Configure social providers — Google + GitHub OAuth credentials
- Register trusted clients — One entry per Moklabs app
- Build login/register UI — Custom pages at auth.moklabs.io
- Deploy — Fly.io / Railway / VPS behind Cloudflare
Phase 2: Migrate Apps (Week 2-3, per app)
For each Moklabs project:
- Install Better Auth + Generic OAuth plugin
- Configure OIDC discovery pointing to auth.moklabs.io
- Replace Supabase auth calls with Better Auth client
- Test SSO flow — Login → redirect → callback → session
- Test social login — Google/GitHub via central auth
- Test token refresh — Verify
offline_accessrefresh works - Remove Supabase auth dependencies
Phase 3: Organization Plugin (Week 4, optional)
- Enable organization plugin on auth server
- Migrate team/workspace data from existing systems
- Add role-based access to apps that need it
Priority Order for App Migration
| Priority | App | Reason |
|---|---|---|
| 1 | Paperclip | Core platform, most users |
| 2 | Jarvis | Tightly coupled with Paperclip |
| 3 | Research (this repo) | Agent-facing, uses client credentials |
| 4-8 | Other apps | Incremental migration |
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Better Auth breaking changes in v2.0 | Medium | High | Pin version, monitor changelog, v1.5 is stable |
| OAuth 2.1 Provider plugin bugs | Low | High | Plugin is well-tested, fallback to OIDC Provider plugin temporarily |
| Cross-subdomain cookie issues | Low | Medium | Standard OIDC redirect flow works without cookies |
| Silent re-auth fails in Safari | Medium | Low | Use refresh tokens (primary), iframe is fallback only |
| Scaling beyond 8 apps | Low | Low | No documented limit on trusted clients |
| PostgreSQL single point of failure | Low | Critical | Use managed PostgreSQL (Neon, Supabase Postgres, RDS) with replicas |
| Better Auth OIDC Provider deprecation | Certain | Low | Already using new OAuth 2.1 Provider plugin |
| Supabase migration data loss | Low | High | Test migration on staging first, keep Supabase running in parallel |
Data Points & Numbers
| Metric | Value | Source |
|---|---|---|
| Better Auth GitHub stars | 26,304 | GitHub |
| Better Auth latest version | v1.5.5 (March 15, 2026) | npm |
| Better Auth v1.5 new features | 70+ | Blog |
| Better Auth v1.5 bug fixes | 200+ | Blog |
| Zitadel GitHub stars | 13,018 | GitHub |
| Zitadel production RAM (min) | 512 MB | Docs |
| Zitadel production CPU (recommended) | 4 cores | Docs |
| OAuth 2.1 access token default expiry | 1 hour | Docs |
| OAuth 2.1 refresh token default expiry | 30 days | Docs |
| Rate limit: token endpoint | 20/min | Docs |
| Rate limit: authorize endpoint | 30/min | Docs |
Sources
- Better Auth — Official Site
- Better Auth OAuth 2.1 Provider Plugin
- Better Auth OIDC Provider Plugin (deprecated)
- Better Auth Drizzle ORM Adapter
- Better Auth Database Concepts
- Better Auth Cookies / Cross-Subdomain
- Better Auth Security Reference
- Better Auth SSO Plugin
- Better Auth v1.5 Blog Post
- Better Auth v1.5.0 Release
- GitHub Discussion: Sharing login state across multiple apps
- Better Auth vs Zitadel Comparison
- Zitadel — Official Site
- Zitadel Self-Hosting Deployment
- Zitadel Production Requirements
- Zitadel GitHub Repository
- Deploying Zitadel with Docker Compose (March 2026)
- Brittle Zitadel — Self-Hosting Challenges
- Better Auth Production Usage Discussion
- Better Auth Organizations Plugin
- ZenStack — Better Auth Multi-Tenant Integration