All reports
Technology by deep-research

Better Auth + OIDC Provider Architecture for Moklabs Multi-App SSO

MoklabsOctantOSRemindrNarrativArgus

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

AspectStatusDetails
Current versionv1.5.5 (stable)Released March 15, 2026 — 600+ commits, 70 new features in v1.5 series
Plugin name@better-auth/oauth-providerNew — replaces deprecated oidc-provider plugin
OIDC compatibilityYesSupports openid, profile, email scopes, UserInfo endpoint, ID tokens
LicenseMITMore permissive than Zitadel’s AGPL-3.0
MCP supportBuilt-inOAuth protected resource metadata at /.well-known/oauth-protected-resource

Supported Grant Types

GrantUse CasePKCE Required
Authorization CodeUser-facing apps (web, mobile, SPA)Yes (S256 only, no plain)
Refresh TokenToken renewal via offline_access scopeN/A
Client CredentialsMachine-to-machine (agents, services)No

Key Endpoints

  • GET /.well-known/openid-configuration — OIDC discovery
  • GET /oauth2/authorize — Authorization endpoint
  • POST /oauth2/token — Token exchange
  • GET /oauth2/userinfo — User info (OIDC)
  • GET /.well-known/jwks.json — JWKS for token verification
  • POST /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

  1. User visits app1.moklabs.io → not authenticated
  2. App1 redirects to auth.moklabs.io/oauth2/authorize with PKCE challenge
  3. User logs in at auth.moklabs.io (or is already logged in via session cookie)
  4. Auth server redirects back with authorization code
  5. App1 exchanges code for access token + ID token + refresh token
  6. 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

ScenarioApproach
Same root domain (*.moklabs.io)Cross-subdomain cookies: crossSubDomainCookies: { enabled: true, domain: "moklabs.io" } on auth server
Different domainsStandard OIDC redirect flow — no cookie sharing needed
Silent re-authUse prompt=none in authorize request via hidden iframe or background fetch
Token refreshEach 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

ParameterDefaultRecommended
Access token expiry1 hour1 hour (keep default)
Refresh token expiry30 days30 days (keep default)
Refresh token rotationYes (new token per refresh)Yes (keep default)
PKCE requirementMandatory for public clientsMandatory for all

Silent Re-Auth Flow

For SPAs and apps that need background token refresh:

  1. Primary method: Use refresh tokens via offline_access scope — each app independently refreshes its own tokens
  2. Fallback: Hidden iframe with prompt=none to auth.moklabs.io — works if user has active session on auth server
  3. 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.

ProviderStatusNotes
GoogleBuilt-inclientId + clientSecret in auth server config
GitHubBuilt-inclientId + clientSecret in auth server config
DiscordBuilt-inAvailable if needed
AppleBuilt-inAvailable 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

TablePurpose
userUser profiles (id, name, email, emailVerified, image, createdAt, updatedAt)
sessionActive sessions (id, userId, token, expiresAt, ipAddress, userAgent)
accountAuth provider links (id, userId, providerId, accountId, accessToken, refreshToken)
verificationEmail verification, password reset tokens

OAuth Provider Additional Tables

TablePurpose
oauthApplicationRegistered OAuth clients (clientId, clientSecret, redirectURIs)
oauthAccessTokenIssued access/refresh tokens
oauthConsentUser 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:

  1. Export users from Supabase
  2. Map Supabase user fields to Better Auth schema
  3. Import accounts with provider mappings
  4. 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

FeatureStatus
Multi-organization per userYes
Built-in roles (owner, admin, member)Yes
Custom rolesYes
Member management (invite, remove)Yes
Organization switchingYes
Fine-grained permissionsYes
Email-based invitations with expirationYes
Org limit per userConfigurable
Member limit per orgConfigurable

Limitations for Moklabs

LimitationImpactMitigation
Organization data is per-app databaseIf using shared auth DB, orgs are shared; if per-app, orgs are isolatedUse shared auth DB for org data
No admin-level access to all orgs without membershipAdmin panel needs custom logicBuild admin override in auth server
Per-tenant provider config only for SSO providersCore providers (email/pass) are globalAcceptable 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)

DimensionBetter Auth + OAuth 2.1 ProviderZitadel
ArchitectureTypeScript library embedded in Hono appGo binary + Next.js login UI (2 containers)
DatabasePostgreSQL (or any supported DB)PostgreSQL 17 only (CockroachDB dropped)
Resource needsMinimal (same as host app)4 CPU cores + 16GB RAM (production HA)
OIDC complianceOAuth 2.1 with OIDC compatFull OIDC/SAML/SCIM/LDAP
Multi-tenancyPlugin-based (org plugin)Built-in with strict hierarchy
Setup complexitynpm install + configDocker Compose + Traefik + DB setup
LicenseMITAGPL-3.0
Audit trailBasic (via DB)Event-sourced (every mutation is an event)
UIBuild your ownBuilt-in login UI
MCP supportBuilt-inNone
Community26,304 GitHub stars, 2 years old13,018 GitHub stars, 6 years old
Maturityv1.5.5, active developmentv4.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.

-- 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 generate to 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)

  1. Create auth.moklabs.io project — Hono + Better Auth + OAuth 2.1 Provider
  2. Set up PostgreSQL — Shared auth database with Drizzle ORM
  3. Configure social providers — Google + GitHub OAuth credentials
  4. Register trusted clients — One entry per Moklabs app
  5. Build login/register UI — Custom pages at auth.moklabs.io
  6. Deploy — Fly.io / Railway / VPS behind Cloudflare

Phase 2: Migrate Apps (Week 2-3, per app)

For each Moklabs project:

  1. Install Better Auth + Generic OAuth plugin
  2. Configure OIDC discovery pointing to auth.moklabs.io
  3. Replace Supabase auth calls with Better Auth client
  4. Test SSO flow — Login → redirect → callback → session
  5. Test social login — Google/GitHub via central auth
  6. Test token refresh — Verify offline_access refresh works
  7. Remove Supabase auth dependencies

Phase 3: Organization Plugin (Week 4, optional)

  1. Enable organization plugin on auth server
  2. Migrate team/workspace data from existing systems
  3. Add role-based access to apps that need it

Priority Order for App Migration

PriorityAppReason
1PaperclipCore platform, most users
2JarvisTightly coupled with Paperclip
3Research (this repo)Agent-facing, uses client credentials
4-8Other appsIncremental migration

Risk Assessment

RiskLikelihoodImpactMitigation
Better Auth breaking changes in v2.0MediumHighPin version, monitor changelog, v1.5 is stable
OAuth 2.1 Provider plugin bugsLowHighPlugin is well-tested, fallback to OIDC Provider plugin temporarily
Cross-subdomain cookie issuesLowMediumStandard OIDC redirect flow works without cookies
Silent re-auth fails in SafariMediumLowUse refresh tokens (primary), iframe is fallback only
Scaling beyond 8 appsLowLowNo documented limit on trusted clients
PostgreSQL single point of failureLowCriticalUse managed PostgreSQL (Neon, Supabase Postgres, RDS) with replicas
Better Auth OIDC Provider deprecationCertainLowAlready using new OAuth 2.1 Provider plugin
Supabase migration data lossLowHighTest migration on staging first, keep Supabase running in parallel

Data Points & Numbers

MetricValueSource
Better Auth GitHub stars26,304GitHub
Better Auth latest versionv1.5.5 (March 15, 2026)npm
Better Auth v1.5 new features70+Blog
Better Auth v1.5 bug fixes200+Blog
Zitadel GitHub stars13,018GitHub
Zitadel production RAM (min)512 MBDocs
Zitadel production CPU (recommended)4 coresDocs
OAuth 2.1 access token default expiry1 hourDocs
OAuth 2.1 refresh token default expiry30 daysDocs
Rate limit: token endpoint20/minDocs
Rate limit: authorize endpoint30/minDocs

Sources

Related Reports