How OpenSEO Handles Authentication for Hosted and Self-Hosted Deployments

OpenSEO uses Better-Auth as its core authentication library and distinguishes hosted from self-hosted deployments through the AUTH_MODE environment variable, with hosted requiring full OAuth credentials and self-hosted using delegated authentication with minimal configuration.

OpenSEO, the open-source SEO platform from every-app/open-seo, implements a flexible dual-mode authentication system. Whether you run the SaaS version or deploy your own instance, the codebase adapts automatically—using the same underlying library but applying different security requirements and plugin configurations based on your deployment type.

Detecting Authentication Mode

The entry point for all authentication logic is the mode detection helper in src/lib/auth-mode.ts:

export const isHostedAuthMode = (mode = env.AUTH_MODE) => mode === "hosted";

This single function branches the entire authentication flow. When AUTH_MODE=hosted, OpenSEO expects a complete SaaS environment with public URLs, OAuth providers, and email services. Any other value triggers self-hosted mode with relaxed requirements and delegated identity resolution.

Creating the Auth Instance

The createAuth() function in src/lib/auth.ts constructs the Better-Auth instance differentially:

function createAuth() {
  const baseUrl = isHostedAuthMode(env.AUTH_MODE)
    ? getHostedBaseUrl()               // real URL for cookies, callbacks, etc.
    : "http://localhost";              // placeholder for self-hosted token minting

  const turnstileSecretKey = getHostedTurnstileSecretKey(env);
  const database = getDatabaseProvider() === "postgres"
    ? drizzleAdapter(pgDb, { provider: "pg", schema: pgSchema })
    : drizzleAdapter(d1Db,    { provider: "sqlite", schema: d1Schema });

  const auth = betterAuth({
    baseURL: baseUrl,
    secret: getHostedSecret(),
    ...baseAuthConfig,
    plugins: [
      ...baseAuthConfig.plugins,
      ...(isHostedAuthMode(env.AUTH_MODE) ? [createApiKeyPlugin()] : []),
      ...(turnstileSecretKey ? [captcha({ provider: "cloudflare-turnstile", secretKey: turnstileSecretKey })] : []),
      tanstackStartCookies(),
    ],
  });

  return auth;
}

Hosted deployments receive:

  • Dynamic baseUrl from BETTER_AUTH_URL
  • API key plugin for per-user API key management
  • Cloudflare Turnstile captcha protection
  • Email verification flows

Self-hosted deployments use:

  • Fixed http://localhost placeholder URL
  • No API key plugin (authentication handled externally)
  • No captcha (relies on upstream provider)
  • Automatic email verification bypass

Validating Hosted Configuration

Before enabling SaaS features, OpenSEO validates that all required secrets are present. The hasHostedAuthConfig() function in src/lib/auth.ts performs this check:

export function hasHostedAuthConfig() {
  try {
    getHostedBaseUrl();          // BETTER_AUTH_URL
    getHostedSecret();           // BETTER_AUTH_SECRET
    getGoogleSocialProviderConfig(); // GOOGLE_CLIENT_ID/SECRET
    return (
      hasHostedTurnstileConfig(env) &&
      (env.BYPASS_EMAIL_VERIFICATION === "true" || hasHostedAuthEmailConfig())
    );
  } catch {
    return false;
  }
  }

This function is used to gate hosted-only routes. If any required environment variable is missing, the check returns false and prevents access to SaaS functionality.

Resolving User Context in Middleware

The core authentication abstraction is EnsuredUserContext, produced by the ensure-user middleware. Three resolvers handle different deployment scenarios:

Hosted Context Resolution

File: src/middleware/ensure-user/hosted.ts

The resolveHostedContext function reads the Better-Auth session cookie via getAuth().api.getSession(), then resolves or creates the active organization through AuthRepository.getMembership and resolveActiveHostedOrganization.

Delegated Context Resolution

File: src/middleware/ensure-user/delegated.ts

Two functions handle self-hosted authentication:

  • resolveDelegatedContext — For Cloudflare Access or local_noauth setups. Ensures a user row exists, creates a delegated organization per user, and assigns role "owner".

  • resolveSharedWorkspaceContext — For Cloudflare Access shared workspace deployments. Creates a single shared workspace organization that all users access.

All resolvers return the unified interface:

interface EnsuredUserContext {
  userId: string;
  userEmail: string;
  emailVerified: boolean;
  organizationId: string;
  role: "owner" | "admin" | …;
}

The middleware entry point in src/middleware/ensure-user.ts selects the appropriate resolver based on env.AUTH_MODE and session presence.

Self-Host Preflight Checks

Before starting in self-hosted mode, runSelfhostPreflight() in src/lib/selfhost-preflight.ts validates:

  • BETTER_AUTH_SECRET is configured
  • Database provider connectivity
  • Optional telemetry opt-out preference

These checks ensure the minimal viable configuration for secure token issuance without requiring external OAuth providers.

Deployment Comparison

Feature Hosted (SaaS) Self-Hosted
Base URL BETTER_AUTH_URL (public domain) http://localhost (placeholder)
Required secrets BETTER_AUTH_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, Turnstile secret, email service keys BETTER_AUTH_SECRET only
Email verification Enforced (bypassable via BYPASS_EMAIL_VERIFICATION=true) Automatically verified
Captcha Cloudflare Turnstile Disabled
API keys Per-user via plugin Disabled
Social login Google OAuth External (Cloudflare Access)
Organization model Session-based with active org selection Delegated or shared workspace

Using the Auth Instance

Both deployment modes expose the same getAuth() interface. A typical route handler works identically:

import { getAuth } from "@/lib/auth";

export async function GET(request: Request) {
  const auth = getAuth();
  const session = await auth.api.getSession({ headers: request.headers });

  if (!session?.user) {
    return new Response("Unauthenticated", { status: 401 });
  }

  return new Response(JSON.stringify({ 
    id: session.user.id, 
    email: session.user.email 
  }));
}

In hosted mode, the session originates from Better-Auth cookies. In self-hosted mode, upstream authentication (e.g., Cloudflare Access headers) is transformed into a session context by the delegated resolver before this handler executes.

Summary

  • Better-Auth powers both modes — OpenSEO uses the same core library regardless of deployment type, ensuring API consistency.

  • AUTH_MODE drives all branching — The environment variable selects between full SaaS authentication and delegated self-hosted access.

  • Hosted requires complete OAuth infrastructure — Google social login, Turnstile captcha, email verification, and per-user API keys are standard.

  • Self-hosted minimizes secrets — Only BETTER_AUTH_SECRET is mandatory; identity is delegated to external providers like Cloudflare Access.

  • Unified context abstraction — The EnsuredUserContext interface lets application code remain deployment-agnostic.

Frequently Asked Questions

What happens if I run OpenSEO without setting AUTH_MODE?

OpenSEO defaults to self-hosted behavior. Without AUTH_MODE=hosted, the system will not require Google OAuth credentials or Turnstile configuration, and user resolution will use the delegated path in src/middleware/ensure-user/delegated.ts.

Can I use Google OAuth in a self-hosted deployment?

The self-hosted mode in src/lib/auth.ts intentionally skips getGoogleSocialProviderConfig() and does not initialize social providers. For Google authentication in self-hosted environments, route requests through Cloudflare Access or another identity provider that sets authentication headers, which resolveDelegatedContext will recognize.

Why does self-hosted mode need BETTER_AUTH_SECRET if it doesn't use full OAuth?

The secret is required for token encryption and session signing in betterAuth(). Even with delegated authentication, OpenSEO mints and refreshes Google Search Console tokens internally, and these operations depend on the encryption key. The runSelfhostPreflight() check in src/lib/selfhost-preflight.ts enforces this requirement.

How do I migrate from self-hosted to hosted mode?

Set AUTH_MODE=hosted and provide all required environment variables: BETTER_AUTH_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, Turnstile secret, and email configuration. The hasHostedAuthConfig() function will validate your setup before enabling hosted features.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →