diff --git a/api/constants.py b/api/constants.py
index e5d9d7f5..b7bc9f74 100644
--- a/api/constants.py
+++ b/api/constants.py
@@ -30,6 +30,12 @@ CORS_ALLOWED_ORIGINS = [
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local")
+# Stack Auth public client config. These are safe to expose to the browser (the
+# publishable client key is public by design, and the project id is non-sensitive),
+# and are served to the UI at runtime via /api/v1/health so the frontend no longer
+# needs them baked into the bundle at build time.
+STACK_AUTH_PROJECT_ID = os.getenv("STACK_AUTH_PROJECT_ID")
+STACK_PUBLISHABLE_CLIENT_KEY = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY")
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
diff --git a/api/routes/main.py b/api/routes/main.py
index 6de59b1d..067f2ab9 100644
--- a/api/routes/main.py
+++ b/api/routes/main.py
@@ -72,6 +72,11 @@ class HealthResponse(BaseModel):
auth_provider: str
turn_enabled: bool
force_turn_relay: bool
+ # Public Stack Auth client config — only populated when auth_provider == "stack".
+ # The UI reads these at runtime to initialize Stack, so they no longer need to
+ # be baked into the browser bundle at build time. Both are public values.
+ stack_project_id: str | None = None
+ stack_publishable_client_key: str | None = None
@router.get("/health", response_model=HealthResponse)
@@ -81,12 +86,15 @@ async def health() -> HealthResponse:
AUTH_PROVIDER,
DEPLOYMENT_MODE,
FORCE_TURN_RELAY,
+ STACK_AUTH_PROJECT_ID,
+ STACK_PUBLISHABLE_CLIENT_KEY,
TURN_SECRET,
)
from api.utils.common import get_backend_endpoints
logger.debug("Health endpoint called")
backend_endpoint, _ = await get_backend_endpoints()
+ is_stack = AUTH_PROVIDER == "stack"
return HealthResponse(
status="ok",
version=APP_VERSION,
@@ -95,4 +103,8 @@ async def health() -> HealthResponse:
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),
force_turn_relay=FORCE_TURN_RELAY,
+ stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None,
+ stack_publishable_client_key=(
+ STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None
+ ),
)
diff --git a/docs/deployment/authentication.mdx b/docs/deployment/authentication.mdx
new file mode 100644
index 00000000..47d05ed0
--- /dev/null
+++ b/docs/deployment/authentication.mdx
@@ -0,0 +1,99 @@
+---
+title: "Authentication"
+description: "Configure how self-hosted Dograh authenticates users — the default local provider, or Stack Auth for social login"
+---
+
+Self-hosted Dograh ships with a built-in **local** authentication provider (email + password, backed by a signed JWT). This is the default and needs no external service.
+
+To offer social logins (Google, GitHub, and others), you can delegate sign-in to **[Stack Auth](https://stack-auth.com)**. Enabling it is a **runtime** configuration change — set a few environment variables and restart. The prebuilt `dograhai/dograh-api` and `dograhai/dograh-ui` images work as-is; you do **not** need to rebuild or build from source.
+
+
+The active provider is controlled by the backend `AUTH_PROVIDER` variable (`local` by default). The frontend discovers the provider — and, for Stack, its public client config — at runtime from the backend's `/api/v1/health` response, so the browser bundle never needs Stack values baked in at build time.
+
+
+## How it works
+
+1. The backend reads `AUTH_PROVIDER` and the Stack settings from its environment.
+2. When `AUTH_PROVIDER=stack`, `/api/v1/health` returns the **public** Stack client config (project id + publishable client key).
+3. The UI fetches that at runtime and initializes the Stack SDK in the browser.
+4. The **secret server key** is used only server-side (by the backend and the UI's server runtime) and is never sent to the browser.
+
+## Prerequisites
+
+A Stack Auth project. Create one in the [Stack Auth dashboard](https://app.stack-auth.com) and configure the social login providers you want to offer.
+
+## Step 1 — Collect your Stack credentials
+
+From your project in the [Stack Auth dashboard](https://app.stack-auth.com), gather:
+
+| Value | Sensitivity |
+|---|---|
+| **Project ID** | Public |
+| **Publishable client key** | Public (safe to expose in the browser) |
+| **Secret server key** | Secret — keep server-side only |
+| **API base URL** | Public. For Stack's hosted service this is `https://api.stack-auth.com` |
+
+## Step 2 — Configure the backend (`api`)
+
+Set these on the `api` service. Add them to the `environment:` block of the `api` service in your `docker-compose.yaml`:
+
+```yaml docker-compose.yaml
+services:
+ api:
+ environment:
+ AUTH_PROVIDER: "stack"
+ STACK_AUTH_PROJECT_ID: ""
+ STACK_PUBLISHABLE_CLIENT_KEY: ""
+ STACK_SECRET_SERVER_KEY: ""
+ STACK_AUTH_API_URL: "https://api.stack-auth.com"
+```
+
+## Step 3 — Configure the UI (`ui`)
+
+The UI runs server-side code (SSR pages and the `/handler/*` auth routes) that calls Stack with the secret server key, so the `ui` service needs that one value too:
+
+```yaml docker-compose.yaml
+services:
+ ui:
+ environment:
+ STACK_SECRET_SERVER_KEY: ""
+```
+
+
+The `ui` service does **not** need the project id or publishable client key — it receives those from the backend at runtime via `/api/v1/health`. Only the secret server key (used server-side) is set here.
+
+
+## Step 4 — Restart and verify
+
+Recreate the containers so they pick up the new environment:
+
+```bash
+docker compose up -d
+```
+
+Confirm the backend reports the active provider and the public client config:
+
+```bash
+curl -s http://localhost:8000/api/v1/health
+# expect: "auth_provider":"stack", plus "stack_project_id" and "stack_publishable_client_key"
+```
+
+Then open the UI. The sign-in page should now present your configured Stack Auth social login options instead of the local email/password form.
+
+## Environment variable reference
+
+| Variable | Service | Secret | Notes |
+|---|---|---|---|
+| `AUTH_PROVIDER` | `api` | — | Set to `stack` (default `local`) |
+| `STACK_AUTH_PROJECT_ID` | `api` | No | Stack project ID; served to the UI at runtime |
+| `STACK_PUBLISHABLE_CLIENT_KEY` | `api` | No | Publishable key; served to the UI at runtime |
+| `STACK_SECRET_SERVER_KEY` | `api` + `ui` | **Yes** | Server-side only — never exposed to the browser |
+| `STACK_AUTH_API_URL` | `api` | No | Stack REST API base URL |
+
+
+`STACK_SECRET_SERVER_KEY` is the only secret here. Keep it out of any client-visible config and never bake it into an image. The project ID and publishable client key are public by design — the backend deliberately serves them to the browser so Stack can initialize at runtime.
+
+
+## Reverting to local auth
+
+Remove the variables above (or set `AUTH_PROVIDER=local`) and restart. The UI detects `local` from the backend at runtime and falls back to the built-in email/password flow — no rebuild required.
diff --git a/docs/developer/environment-variables.mdx b/docs/developer/environment-variables.mdx
index d7f480bf..559bc30b 100644
--- a/docs/developer/environment-variables.mdx
+++ b/docs/developer/environment-variables.mdx
@@ -22,7 +22,7 @@ The relevant required variables for each mode are noted in the descriptions belo
|---|---|---|
| `ENVIRONMENT` | `local` | Runtime environment. Affects logging and behaviour. One of `local`, `production`, `test` |
| `DEPLOYMENT_MODE` | `oss` | Deployment mode. Use `oss` for self-hosted |
-| `AUTH_PROVIDER` | `local` | Authentication provider. Use `local` for OSS |
+| `AUTH_PROVIDER` | `local` | Authentication provider. `local` (default) uses the built-in email/password flow. Set to `stack` to delegate to Stack Auth for social login — see [Authentication](/deployment/authentication) for the full setup |
---
@@ -48,6 +48,19 @@ Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate
---
+## Authentication (Stack Auth)
+
+Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https://stack-auth.com) for social login. The project id and publishable client key are public and are served to the browser at runtime via `/api/v1/health`; the secret server key stays server-side. See [Authentication](/deployment/authentication) for the full walkthrough.
+
+| Variable | Default | Description |
+|---|---|---|
+| `STACK_AUTH_PROJECT_ID` | `null` | **Required for `stack`.** Stack project ID (public) |
+| `STACK_PUBLISHABLE_CLIENT_KEY` | `null` | **Required for `stack`.** Stack publishable client key (public) |
+| `STACK_SECRET_SERVER_KEY` | `null` | **Required for `stack`.** Stack secret server key — server-side only, also set on the `ui` service. Keep secret |
+| `STACK_AUTH_API_URL` | `null` | **Required for `stack`.** Stack REST API base URL (e.g. `https://api.stack-auth.com`) |
+
+---
+
## URLs
| Variable | Default | Description |
diff --git a/docs/docs.json b/docs/docs.json
index 2bb9924e..21f2225f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -157,6 +157,7 @@
"pages": [
"deployment/introduction",
"deployment/docker",
+ "deployment/authentication",
"deployment/custom-domain",
"deployment/scaling",
"deployment/update",
diff --git a/ui/Dockerfile b/ui/Dockerfile
index e6ab02da..80dac3fb 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -42,15 +42,6 @@ ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com"
ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X"
ENV BACKEND_URL="http://api:8000"
-# Stack Auth (optional, for self-hosted social login). NEXT_PUBLIC_* values must
-# be set at build time so Next.js inlines them into the browser bundle; setting
-# them as runtime container env has no effect. Unset by default (empty strings),
-# which leaves the official image on the built-in local auth flow.
-ARG NEXT_PUBLIC_STACK_PROJECT_ID
-ARG NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY
-ENV NEXT_PUBLIC_STACK_PROJECT_ID=$NEXT_PUBLIC_STACK_PROJECT_ID
-ENV NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=$NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY
-
# Build the application with standalone mode
# Increase Node.js heap size to prevent out-of-memory errors during build
ENV NODE_OPTIONS="--max-old-space-size=4096"
diff --git a/ui/src/app/api/config/auth/route.ts b/ui/src/app/api/config/auth/route.ts
index a3b33477..cf6553f3 100644
--- a/ui/src/app/api/config/auth/route.ts
+++ b/ui/src/app/api/config/auth/route.ts
@@ -1,10 +1,17 @@
import { NextResponse } from 'next/server';
-import { getAuthProvider } from '@/lib/auth/config';
+import { getAuthProvider, getStackConfig } from '@/lib/auth/config';
import logger from '@/lib/logger';
export async function GET() {
const provider = await getAuthProvider();
+ // When using Stack, hand the public client config to the browser so it can
+ // initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed).
+ const stackConfig = provider === 'stack' ? await getStackConfig() : null;
logger.debug(`Got provider ${provider} from getAuthProvider`)
- return NextResponse.json({ provider });
+ return NextResponse.json({
+ provider,
+ stackProjectId: stackConfig?.projectId ?? null,
+ stackPublishableClientKey: stackConfig?.publishableClientKey ?? null,
+ });
}
diff --git a/ui/src/app/impersonate/route.ts b/ui/src/app/impersonate/route.ts
index c1119d29..421d995b 100644
--- a/ui/src/app/impersonate/route.ts
+++ b/ui/src/app/impersonate/route.ts
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
+import { getStackConfig } from "@/lib/auth/config";
+
/**
* Helper route that receives a refresh token via query parameters, stores it as
* the regular Stack cookie *for the current sub-domain only* and finally
@@ -18,6 +20,13 @@ export async function GET(request: NextRequest) {
return new Response("Missing refresh_token", { status: 400 });
}
+ // The Stack session cookie is named `stack-refresh-`. The project
+ // id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed.
+ const stackConfig = await getStackConfig();
+ if (!stackConfig) {
+ return new Response("Stack auth is not configured", { status: 400 });
+ }
+
// Prepare redirect – if the supplied redirect path is an absolute URL we use
// it as-is, otherwise we resolve it relative to the current request.
const redirectUrl = redirectPath.startsWith("http")
@@ -32,7 +41,7 @@ export async function GET(request: NextRequest) {
// Store the refresh token cookie without an explicit domain so that it is
// scoped to the current (sub-)domain. This avoids collisions between the
// admin (superadmin.*) and the regular app (app.*) domains.
- response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, {
+ response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
path: "/",
maxAge,
secure: true,
diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts
index 1958297d..70d03bfb 100644
--- a/ui/src/lib/auth/config.ts
+++ b/ui/src/lib/auth/config.ts
@@ -2,15 +2,29 @@ import "server-only";
import { getServerBackendUrl } from "@/lib/apiClient";
-let cachedAuthProvider: string | null = null;
+export interface StackConfig {
+ projectId: string;
+ publishableClientKey: string;
+}
+
+interface ResolvedAuthConfig {
+ authProvider: string;
+ stackConfig: StackConfig | null;
+}
+
+let cachedConfig: ResolvedAuthConfig | null = null;
/**
- * Fetches the auth provider from the backend health endpoint and caches it.
- * Falls back to 'local' on error.
+ * Fetches the auth configuration from the backend health endpoint and caches it.
+ *
+ * The backend reports the active auth provider and — when it is `stack` — the
+ * public Stack client config (project id + publishable client key). The UI uses
+ * these at runtime to initialize Stack Auth, so they no longer need to be baked
+ * into the browser bundle at build time. Falls back to local auth on error.
*/
-export async function getAuthProvider(): Promise {
- if (cachedAuthProvider) {
- return cachedAuthProvider;
+async function resolveAuthConfig(): Promise {
+ if (cachedConfig) {
+ return cachedConfig;
}
try {
@@ -20,13 +34,39 @@ export async function getAuthProvider(): Promise {
});
if (res.ok) {
const data = await res.json();
- cachedAuthProvider = (data.auth_provider as string) || "local";
- return cachedAuthProvider;
+ const authProvider = (data.auth_provider as string) || "local";
+ const stackConfig =
+ authProvider === "stack" &&
+ data.stack_project_id &&
+ data.stack_publishable_client_key
+ ? {
+ projectId: data.stack_project_id as string,
+ publishableClientKey:
+ data.stack_publishable_client_key as string,
+ }
+ : null;
+ cachedConfig = { authProvider, stackConfig };
+ return cachedConfig;
}
} catch {
// Backend not reachable — fall back to local
}
- cachedAuthProvider = "local";
- return cachedAuthProvider;
+ cachedConfig = { authProvider: "local", stackConfig: null };
+ return cachedConfig;
+}
+
+/**
+ * Returns the active auth provider ('local' or 'stack'). Falls back to 'local'.
+ */
+export async function getAuthProvider(): Promise {
+ return (await resolveAuthConfig()).authProvider;
+}
+
+/**
+ * Returns the public Stack client config when the active provider is `stack`,
+ * otherwise null. Server-only — the browser receives these via /api/config/auth.
+ */
+export async function getStackConfig(): Promise {
+ return (await resolveAuthConfig()).stackConfig;
}
diff --git a/ui/src/lib/auth/providers/AuthProvider.tsx b/ui/src/lib/auth/providers/AuthProvider.tsx
index 2c83f709..765bb0d6 100644
--- a/ui/src/lib/auth/providers/AuthProvider.tsx
+++ b/ui/src/lib/auth/providers/AuthProvider.tsx
@@ -42,31 +42,57 @@ const LoadingFallback = (
);
+interface ResolvedAuthConfig {
+ provider: string;
+ // Public Stack client config, fetched from the backend at runtime. Null unless
+ // the provider is 'stack' and the backend supplied both values.
+ stack: { projectId: string; publishableClientKey: string } | null;
+}
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
- const [authProvider, setAuthProvider] = useState(null);
+ const [config, setConfig] = useState(null);
useEffect(() => {
fetch('/api/config/auth')
.then((res) => res.json())
.then((data) => {
logger.debug(`Setting auth provider as ${data.provider}`)
- setAuthProvider(data.provider || 'stack')
- })
+ setConfig({
+ provider: data.provider || 'local',
+ stack:
+ data.stackProjectId && data.stackPublishableClientKey
+ ? {
+ projectId: data.stackProjectId,
+ publishableClientKey: data.stackPublishableClientKey,
+ }
+ : null,
+ })
+ })
.catch((e) => {
logger.error(`Got error ${e} while setting auth provider`)
- setAuthProvider('local')
+ setConfig({ provider: 'local', stack: null })
});
}, []);
- if (!authProvider) {
+ if (!config) {
return LoadingFallback;
}
// For Stack provider, use the dedicated wrapper
- if (authProvider === 'stack') {
+ if (config.provider === 'stack') {
+ if (!config.stack) {
+ logger.error(
+ 'Auth provider is "stack" but the backend returned no Stack client config. ' +
+ 'Ensure STACK_AUTH_PROJECT_ID and STACK_PUBLISHABLE_CLIENT_KEY are set on the API service.'
+ );
+ return LoadingFallback;
+ }
return (
-
+
{children}
diff --git a/ui/src/lib/auth/providers/StackProviderWrapper.tsx b/ui/src/lib/auth/providers/StackProviderWrapper.tsx
index 7c6e865e..128ecbc4 100644
--- a/ui/src/lib/auth/providers/StackProviderWrapper.tsx
+++ b/ui/src/lib/auth/providers/StackProviderWrapper.tsx
@@ -9,10 +9,18 @@ import { AuthContext } from './AuthProvider';
// Create a singleton StackClientApp instance to prevent multiple initializations
let stackClientAppInstance: StackClientApp | null = null;
-function getStackClientApp(): StackClientApp {
+function getStackClientApp(
+ projectId: string,
+ publishableClientKey: string,
+): StackClientApp {
if (!stackClientAppInstance) {
+ // projectId / publishableClientKey are passed explicitly (fetched from the
+ // backend at runtime) instead of being read from inlined NEXT_PUBLIC_* env,
+ // so the prebuilt image works without build-time configuration.
stackClientAppInstance = new StackClientApp({
tokenStore: "nextjs-cookie",
+ projectId,
+ publishableClientKey,
urls: {
afterSignIn: "/after-sign-in"
}
@@ -23,6 +31,8 @@ function getStackClientApp(): StackClientApp {
interface StackProviderWrapperProps {
children: React.ReactNode;
+ projectId: string;
+ publishableClientKey: string;
}
// Simple context provider that uses Stack's useUser directly
@@ -114,8 +124,8 @@ const translationOverrides = {
"Sign up with {provider}": "Sign up with {provider} Business",
};
-export function StackProviderWrapper({ children }: StackProviderWrapperProps) {
- const stackClientApp = getStackClientApp();
+export function StackProviderWrapper({ children, projectId, publishableClientKey }: StackProviderWrapperProps) {
+ const stackClientApp = getStackClientApp(projectId, publishableClientKey);
return (
diff --git a/ui/src/lib/auth/server.ts b/ui/src/lib/auth/server.ts
index c27f595f..2fd881ee 100644
--- a/ui/src/lib/auth/server.ts
+++ b/ui/src/lib/auth/server.ts
@@ -5,7 +5,7 @@ import { cookies } from 'next/headers';
import logger from '@/lib/logger';
-import { getAuthProvider } from './config';
+import { getAuthProvider, getStackConfig } from './config';
import type { LocalUser } from './types';
// Server-side auth utilities for SSR pages
@@ -21,10 +21,23 @@ export async function getStackServerApp(): Promise