# How OpenSEO Handles Authentication for Hosted and Self-Hosted Deployments

> Discover how OpenSEO handles authentication across hosted and self-hosted deployments using Better-Auth and the AUTH_MODE variable for secure, flexible access. Learn about OAuth and delegated authentication.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-09-02

---

**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](https://github.com/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`](https://github.com/every-app/open-seo/blob/main/src/lib/auth-mode.ts):

```typescript
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`](https://github.com/every-app/open-seo/blob/main/src/lib/auth.ts) constructs the Better-Auth instance differentially:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/src/lib/auth.ts) performs this check:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

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

```

The middleware entry point in [`src/middleware/ensure-user.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.