diff --git a/.env.example b/.env.example
index 7e1d124c72..d8dece40c3 100755
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,15 @@ VITE_PORT=5173
# Use 127.0.0.1 to restrict to localhost only
HOST=0.0.0.0
+# Disable CloudCLI's local login only when an upstream proxy already enforces
+# authentication (for example, Cloudflare Access with Google OAuth) and the
+# server is bound privately so it cannot be reached around that proxy. The
+# upstream policy must cover the entire hostname, including API and WebSocket
+# routes. An existing local user is required. Backend checks DISABLE_AUTH; Vite
+# embeds VITE_DISABLE_AUTH into the frontend bundle, so set both before building.
+# DISABLE_AUTH=true
+# VITE_DISABLE_AUTH=true
+
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude
@@ -41,5 +50,3 @@ HOST=0.0.0.0
# Claude Code context window size (maximum tokens per session)
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000
-
-
diff --git a/README.md b/README.md
index 9fa3d66555..8e1a657a48 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,30 @@ Open `http://localhost:3001` — all your existing sessions are discovered autom
Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
+#### Authentication behind a reverse proxy
+
+CloudCLI requires its own local login by default. When an upstream proxy already
+enforces authentication, you can disable the second login. For example, create
+a Cloudflare Access self-hosted application for the CloudCLI hostname and attach
+an Access policy that allows your Google Workspace identity (or selected Google
+users). Requests are then challenged by Google OAuth at Cloudflare before the
+tunnel forwards them to CloudCLI.
+
+After the upstream login is enforced, set both variables before building and
+starting CloudCLI:
+
+```bash
+DISABLE_AUTH=true
+VITE_DISABLE_AUTH=true
+```
+
+This mode uses the installation's existing single local user. Complete the
+normal first-user setup before enabling it. Bind CloudCLI to `127.0.0.1` (or an
+otherwise private interface), point `cloudflared` at that private origin, and do
+not expose the CloudCLI port publicly. The Access application must cover the
+whole hostname, including `/api`, `/ws`, and `/shell`. Otherwise a direct or
+uncovered request would be accepted without CloudCLI credentials.
+
#### Docker Sandboxes (Experimental)
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
diff --git a/server/cli.js b/server/cli.js
index 08d3af48a5..5e381a8ecc 100755
--- a/server/cli.js
+++ b/server/cli.js
@@ -120,6 +120,7 @@ function showStatus() {
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
+ console.log(` DISABLE_AUTH: ${c.dim(process.env.DISABLE_AUTH || process.env.VITE_DISABLE_AUTH || 'false (default)')}`);
// Claude projects folder
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
@@ -181,6 +182,8 @@ Environment Variables:
DATABASE_PATH Set custom database location
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
+ DISABLE_AUTH Trust upstream proxy authentication and bypass local login
+ VITE_DISABLE_AUTH Frontend build flag paired with DISABLE_AUTH
Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
diff --git a/server/constants/config.js b/server/constants/config.js
index 580a985745..9b125892de 100644
--- a/server/constants/config.js
+++ b/server/constants/config.js
@@ -2,4 +2,6 @@
* Environment Flag: Is Platform
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
-export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
\ No newline at end of file
+export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
+export const DISABLE_AUTH = process.env.DISABLE_AUTH === 'true' || process.env.VITE_DISABLE_AUTH === 'true';
+export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH;
diff --git a/server/index.js b/server/index.js
index 0832c4905b..e6458653b7 100755
--- a/server/index.js
+++ b/server/index.js
@@ -67,7 +67,7 @@ import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './util
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
-import { IS_PLATFORM } from './constants/config.js';
+import { TRUST_LOCAL_AUTH_BYPASS } from './constants/config.js';
import { c } from './utils/colors.js';
const __dirname = getModuleDir(import.meta.url);
@@ -105,7 +105,7 @@ const server = http.createServer(app);
// Single WebSocket server that handles chat, shell, and plugin proxy paths.
const wss = createWebSocketServer(server, {
verifyClient: {
- isPlatform: IS_PLATFORM,
+ isPlatform: TRUST_LOCAL_AUTH_BYPASS,
authenticateWebSocket,
},
chat: {
diff --git a/server/middleware/auth-bypass.test.js b/server/middleware/auth-bypass.test.js
new file mode 100644
index 0000000000..f07518b1fb
--- /dev/null
+++ b/server/middleware/auth-bypass.test.js
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+process.env.JWT_SECRET = 'test-secret';
+process.env.DISABLE_AUTH = 'true';
+
+const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'cloudcli-auth-bypass-'));
+process.env.DATABASE_PATH = path.join(temporaryDirectory, 'auth.db');
+
+const { initializeDatabase } = await import('../modules/database/init-db.js');
+const { closeConnection } = await import('../modules/database/connection.js');
+const { userDb } = await import('../modules/database/index.js');
+const { authenticateToken, authenticateWebSocket, getLocalBypassUser } = await import('./auth.js');
+const { verifyWebSocketClient } = await import('../modules/websocket/services/websocket-auth.service.ts');
+
+await initializeDatabase();
+
+test.after(async () => {
+ closeConnection();
+ await rm(temporaryDirectory, { recursive: true, force: true });
+});
+
+test('authentication bypass fails closed until a local user exists', async () => {
+ assert.equal(getLocalBypassUser(), undefined);
+ assert.equal(authenticateWebSocket(null), null);
+
+ let statusCode = null;
+ let responseBody = null;
+ await authenticateToken({}, {
+ status(code) {
+ statusCode = code;
+ return this;
+ },
+ json(body) {
+ responseBody = body;
+ return this;
+ },
+ }, () => assert.fail('middleware must not continue without a local user'));
+
+ assert.equal(statusCode, 503);
+ assert.match(responseBody.error, /existing local user/i);
+});
+
+test('authentication bypass uses the existing single local user', async () => {
+ userDb.createUser('admin', 'existing-password-hash');
+
+ const socketUser = authenticateWebSocket(null);
+ assert.equal(socketUser.username, 'admin');
+
+ const request = {};
+ let continued = false;
+ await authenticateToken(request, {}, () => {
+ continued = true;
+ });
+
+ assert.equal(continued, true);
+ assert.equal(request.user.username, 'admin');
+});
+
+test('authentication bypass accepts WebSocket upgrades without a token', () => {
+ const request = {
+ url: '/ws',
+ headers: {},
+ };
+
+ const accepted = verifyWebSocketClient({ req: request }, {
+ isPlatform: true,
+ authenticateWebSocket,
+ });
+
+ assert.equal(accepted, true);
+ assert.equal(request.user.username, 'admin');
+});
diff --git a/server/middleware/auth.js b/server/middleware/auth.js
index c40237b2be..86ebd10a62 100644
--- a/server/middleware/auth.js
+++ b/server/middleware/auth.js
@@ -1,10 +1,12 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../modules/database/index.js';
-import { IS_PLATFORM } from '../constants/config.js';
+import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config.js';
// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
+const getLocalBypassUser = () => userDb.getFirstUser();
+
// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
@@ -21,18 +23,19 @@ const validateApiKey = (req, res, next) => {
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
- // Platform mode: use single database user
- if (IS_PLATFORM) {
+ // Trusted deployment mode: the hosting platform or upstream proxy already
+ // authenticated the request, so use the installation's single local user.
+ if (TRUST_LOCAL_AUTH_BYPASS) {
try {
- const user = userDb.getFirstUser();
+ const user = getLocalBypassUser();
if (!user) {
- return res.status(500).json({ error: 'Platform mode: No user found in database' });
+ return res.status(503).json({ error: 'Authentication bypass requires an existing local user' });
}
req.user = user;
return next();
} catch (error) {
- console.error('Platform mode error:', error);
- return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
+ console.error('Authentication bypass error:', error);
+ return res.status(500).json({ error: 'Authentication bypass failed' });
}
}
@@ -90,16 +93,16 @@ const generateToken = (user) => {
// WebSocket authentication function
const authenticateWebSocket = (token) => {
- // Platform mode: bypass token validation, return first user
- if (IS_PLATFORM) {
+ // Trusted deployment mode: bypass token validation and use the local user.
+ if (TRUST_LOCAL_AUTH_BYPASS) {
try {
- const user = userDb.getFirstUser();
+ const user = getLocalBypassUser();
if (user) {
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
- console.error('Platform mode WebSocket error:', error);
+ console.error('Authentication bypass WebSocket error:', error);
return null;
}
}
@@ -128,5 +131,6 @@ export {
authenticateToken,
generateToken,
authenticateWebSocket,
+ getLocalBypassUser,
JWT_SECRET
};
diff --git a/server/routes/auth.js b/server/routes/auth.js
index dcb2e3ff5b..c862429d96 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -2,7 +2,8 @@ import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../modules/database/index.js';
import { getConnection } from '../modules/database/connection.js';
-import { generateToken, authenticateToken } from '../middleware/auth.js';
+import { generateToken, authenticateToken, getLocalBypassUser } from '../middleware/auth.js';
+import { DISABLE_AUTH } from '../constants/config.js';
const router = express.Router();
const db = getConnection();
@@ -10,6 +11,21 @@ const db = getConnection();
// Check auth status and setup requirements
router.get('/status', async (req, res) => {
try {
+ if (DISABLE_AUTH) {
+ const user = getLocalBypassUser();
+ if (!user) {
+ return res.status(503).json({
+ error: 'DISABLE_AUTH requires an existing local user. Disable it temporarily to complete setup.'
+ });
+ }
+
+ return res.json({
+ needsSetup: false,
+ isAuthenticated: true,
+ user
+ });
+ }
+
const hasUsers = await userDb.hasUsers();
res.json({
needsSetup: !hasUsers,
diff --git a/server/routes/user.js b/server/routes/user.js
index 95215a8c0d..6953674da3 100644
--- a/server/routes/user.js
+++ b/server/routes/user.js
@@ -3,6 +3,7 @@ import express from 'express';
import spawn from 'cross-spawn';
import { userDb } from '../modules/database/index.js';
import { authenticateToken } from '../middleware/auth.js';
+import { DISABLE_AUTH } from '../constants/config.js';
import { getSystemGitConfig } from '../utils/gitConfig.js';
const router = express.Router();
@@ -108,6 +109,13 @@ router.post('/complete-onboarding', authenticateToken, async (req, res) => {
router.get('/onboarding-status', authenticateToken, async (req, res) => {
try {
+ if (DISABLE_AUTH) {
+ return res.json({
+ success: true,
+ hasCompletedOnboarding: true
+ });
+ }
+
const userId = req.user.id;
const hasCompleted = userDb.hasCompletedOnboarding(userId);
diff --git a/src/components/auth/context/AuthContext.tsx b/src/components/auth/context/AuthContext.tsx
index 69bdd8a5d0..9b41354a2e 100644
--- a/src/components/auth/context/AuthContext.tsx
+++ b/src/components/auth/context/AuthContext.tsx
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { IS_PLATFORM } from '../../../constants/config';
+import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import { api } from '../../../utils/api';
import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants';
import type {
@@ -116,10 +116,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, [checkOnboardingStatus, clearSession, token]);
useEffect(() => {
- if (IS_PLATFORM) {
- setUser({ username: 'platform-user' });
+ if (TRUST_LOCAL_AUTH_BYPASS) {
+ setUser({ username: DISABLE_AUTH ? 'local-user' : 'platform-user' });
setNeedsSetup(false);
- void checkOnboardingStatus().finally(() => {
+ const finish = DISABLE_AUTH ? Promise.resolve() : checkOnboardingStatus();
+ void finish.finally(() => {
setIsLoading(false);
});
return;
diff --git a/src/components/auth/view/ProtectedRoute.tsx b/src/components/auth/view/ProtectedRoute.tsx
index d94dcaf189..3f7eeedcd6 100644
--- a/src/components/auth/view/ProtectedRoute.tsx
+++ b/src/components/auth/view/ProtectedRoute.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
-import { IS_PLATFORM } from '../../../constants/config';
+import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import { useAuth } from '../context/AuthContext';
import Onboarding from '../../onboarding/view/Onboarding';
import AuthLoadingScreen from './AuthLoadingScreen';
@@ -17,7 +17,11 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
return ;
}
- if (IS_PLATFORM) {
+ if (TRUST_LOCAL_AUTH_BYPASS) {
+ if (DISABLE_AUTH) {
+ return <>{children}>;
+ }
+
if (!hasCompletedOnboarding) {
return ;
}
diff --git a/src/components/file-tree/hooks/useFileTreeUpload.ts b/src/components/file-tree/hooks/useFileTreeUpload.ts
index dbd9370076..852d654063 100644
--- a/src/components/file-tree/hooks/useFileTreeUpload.ts
+++ b/src/components/file-tree/hooks/useFileTreeUpload.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
-import { IS_PLATFORM } from '../../../constants/config';
+import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import type { Project } from '../../../types/app';
import { isValidRefreshedToken } from '../../../utils/api';
import {
@@ -116,7 +116,7 @@ const uploadFormDataWithProgress = (
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
const token = localStorage.getItem('auth-token');
- if (!IS_PLATFORM && token) {
+ if (!TRUST_LOCAL_AUTH_BYPASS && token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
diff --git a/src/components/shell/utils/socket.ts b/src/components/shell/utils/socket.ts
index 6cb18d6265..9f2d5d126f 100644
--- a/src/components/shell/utils/socket.ts
+++ b/src/components/shell/utils/socket.ts
@@ -1,10 +1,10 @@
-import { IS_PLATFORM } from '../../../constants/config';
+import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';
export function getShellWebSocketUrl(): string | null {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- if (IS_PLATFORM) {
+ if (TRUST_LOCAL_AUTH_BYPASS) {
return `${protocol}//${window.location.host}/shell`;
}
@@ -29,4 +29,4 @@ export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMe
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
-}
\ No newline at end of file
+}
diff --git a/src/constants/config.ts b/src/constants/config.ts
index 6aa7885b4b..39159e5ac8 100644
--- a/src/constants/config.ts
+++ b/src/constants/config.ts
@@ -3,6 +3,8 @@
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
+export const DISABLE_AUTH = import.meta.env.VITE_DISABLE_AUTH === 'true';
+export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH;
/**
* For empty shell instances where no project is provided,
@@ -18,4 +20,4 @@ export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = {
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',
-};
\ No newline at end of file
+};
diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx
index ef62f3fb24..2e5f0bd270 100644
--- a/src/contexts/WebSocketContext.tsx
+++ b/src/contexts/WebSocketContext.tsx
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../components/auth/context/AuthContext';
-import { IS_PLATFORM } from '../constants/config';
+import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config';
/**
* One frame received from the chat websocket. The server guarantees every
@@ -53,7 +53,7 @@ export const useWebSocket = () => {
const buildWebSocketUrl = (token: string | null) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
+ if (TRUST_LOCAL_AUTH_BYPASS) return `${protocol}//${window.location.host}/ws`; // Auth is handled by the platform or upstream proxy.
if (!token) return null;
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
};
diff --git a/src/utils/api.js b/src/utils/api.js
index e5eb3fbcd4..df01d76f68 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,4 +1,4 @@
-import { IS_PLATFORM } from "../constants/config";
+import { TRUST_LOCAL_AUTH_BYPASS } from "../constants/config";
// Only accept a refreshed token that has this app's issued JWT shape
// (three base64url segments). An attacker-injected/malformed header value
@@ -22,7 +22,7 @@ export const authenticatedFetch = (url, options = {}) => {
defaultHeaders['Content-Type'] = 'application/json';
}
- if (!IS_PLATFORM && token) {
+ if (!TRUST_LOCAL_AUTH_BYPASS && token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}