Skip to content

Dashboard prompts for the API token once per in-flight /api request on non-loopback binds #647

Description

@nbsp1221

Client or integration

OpenCodex dashboard

Area

Dashboard

Summary

On a non-loopback bind (hostname: "0.0.0.0", so OPENCODEX_API_AUTH_TOKEN is required), opening the dashboard shows one window.prompt("OpenCodex API token") per management request in flight rather than one per page load. Entering the correct token at the first prompt does not stop the following ones, so the symptom reads as "the token was wrong, try again". After answering every prompt the dashboard works normally, and the whole sequence repeats on the next reload.

Measured. A burst of 14 concurrent /api/* requests produces 14 prompts, and every request ends in 200 with the same token:

requests:       14
prompt calls:   14
final statuses: 200

Expected: one prompt, after which every pending request retries with that token.

GET / and GET /healthz are not gated, which is why the page renders first and the prompts start afterwards.

Root cause

Two things in gui/src/api.ts on main combine.

async function promptForToken(): Promise<string | null> {
  if (promptInFlight) return promptInFlight;
  promptInFlight = Promise.resolve()
    .then(() => window.prompt("OpenCodex API token")?.trim() || null)
    .finally(() => { promptInFlight = null; });
  return promptInFlight;
}

window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
  if (!needsApiAuth(input)) return originalFetch(input, init);

  const token = readToken();                    // captured before the request
  const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init];
  const response = await originalFetch(firstInput, firstInit);
  if (response.status !== 401) return response;

  if (token) clearToken();
  const nextToken = await promptForToken();     // never re-reads readToken()
  ...
};
  1. After the 401, the handler never re-reads readToken(). A request that started before any token existed keeps its captured null and prompts even though another request has already stored a valid token through storeToken().
  2. promptInFlight only deduplicates 401s that land in the same microtask batch. window.prompt blocks the main thread, so any response arriving while a prompt is open is processed only after that prompt closes — and by then .finally has reset promptInFlight to null, so each continuation opens a new prompt.

The prompt count therefore tracks the number of /api/* requests in flight without a token, which on a dashboard page load is its whole initial fan-out.

Why this may not have surfaced earlier

The deduplication gap has been there since gui/src/api.ts was added in c29ee783e (2026-06-27), but it was masked because the token was persisted:

// c29ee783e
function readToken(): string | null {
  try {
    const token = sessionStorage.getItem(TOKEN_KEY)?.trim();
    return token || null;
  } catch {
    return null;
  }
}

function storeToken(token: string): void {
  try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* session storage may be disabled */ }
}

With persistence the storm could happen at most once per browser session, and every later load attached the token to the first attempt.

9c7e922eb (2026-07-25T23:17Z) switched to let memoryToken"In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage)" — and 138751f78 (2026-07-26) additionally wipes the legacy key without reading it. That hardening looks correct on its own; the consequence is that every page load now starts with no token, so the storm reproduces on every load. 9c7e922eb lands after the 2.7.40 release (published 2026-07-25T14:26Z), so by the npm timeline the amplified behaviour first ships in 2.7.41. This report is from 2.7.42.

This is distinct from #570, which concerns the loopback Host admission gate returning 403 when token auth is not required. Here token auth is enabled, the responses are 401, and the problem is the prompt loop rather than admission.

Reproduction

  1. Start the proxy with a non-loopback bind so token auth is enforced.
{ "port": 10100, "hostname": "0.0.0.0" }
export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"
ocx start
  1. Open the dashboard in a browser and enter the correct token at the first prompt. It keeps prompting; a single page load asked me for the token more than ten times before it settled.

  2. To count it without a browser, run the interceptor logic against the running proxy with a synchronous prompt stub. window.prompt is synchronous in a browser, so a synchronous stub preserves the ordering that matters here.

// repro.mjs — node >= 18
// Logic copied from gui/src/api.ts on main. withToken is simplified because this
// harness only passes URL strings, never Request objects.
const BASE = 'http://127.0.0.1:10100';
const TOKEN = process.env.OPENCODEX_API_AUTH_TOKEN;

let promptCalls = 0;
const win = { prompt() { promptCalls += 1; return TOKEN; } };

let promptInFlight = null;
let memoryToken = null;
const readToken = () => memoryToken;
const storeToken = (t) => { memoryToken = t; };
const clearToken = () => { memoryToken = null; };

function withToken(input, init, token) {
  const headers = new Headers(init?.headers ?? undefined);
  headers.set('X-OpenCodex-API-Key', token);
  return [input, { ...init, headers }];
}

async function promptForToken() {
  if (promptInFlight) return promptInFlight;
  promptInFlight = Promise.resolve()
    .then(() => win.prompt('OpenCodex API token')?.trim() || null)
    .finally(() => { promptInFlight = null; });
  return promptInFlight;
}

const originalFetch = globalThis.fetch;
const authFetch = async (input, init) => {
  const token = readToken();
  const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init];
  const response = await originalFetch(firstInput, firstInit);
  if (response.status !== 401) return response;

  if (token) clearToken();
  const nextToken = await promptForToken();
  if (!nextToken) return response;

  storeToken(nextToken);
  const [retryInput, retryInit] = withToken(input, init, nextToken);
  const retry = await originalFetch(retryInput, retryInit);
  if (retry.status === 401) clearToken();
  return retry;
};

// A representative management fan-out.
const endpoints = [
  '/api/config', '/api/providers', '/api/models', '/api/selected-models',
  '/api/disabled-models', '/api/effort-caps', '/api/sidecar-settings',
  '/api/injection-model', '/api/v2', '/api/keys', '/api/provider-presets',
  '/api/key-providers', '/api/oauth/providers', '/api/codex-auth/accounts',
];

const results = await Promise.all(endpoints.map(p => authFetch(BASE + p).then(r => r.status)));
console.log('requests:      ', endpoints.length);
console.log('prompt calls:  ', promptCalls);
console.log('final statuses:', [...new Set(results)].join(','));
  1. One prompt is issued per request even though the first one already returned a valid token.

Possible fix

Re-read the current token after the 401 and retry with it before prompting, so requests that were already in flight ride on the token another request obtained. Sketch rather than a prescription:

if (token) clearToken();

// Another request may have supplied a token while this one was in flight.
const refreshed = readToken();
if (refreshed && refreshed !== token) {
  const [input2, init2] = withToken(input, init, refreshed);
  const retry = await originalFetch(input2, init2);
  if (retry.status !== 401) return retry;
}

const nextToken = await promptForToken();

Serialising concurrent 401s on a single shared refresh gate, instead of the current promptInFlight that only covers one microtask batch, would remove the race more thoroughly. Either way the memory-only property from 9c7e922eb and 138751f78 is preserved, since the fix only consults the existing in-memory value and never touches storage.

Happy to open a PR against dev if that is welcome. gui/src/api.ts is not in the CODEOWNERS authentication group, but it is auth-adjacent, so I would rather ask first.

Version

2.7.42

Operating system

Ubuntu 24.04.4 LTS (proxy host, Docker); browser client on Windows 11

Logs or error output

$ node repro.mjs
requests:       14
prompt calls:   14
final statuses: 200

Redacted configuration

{
  "port": 10100,
  "hostname": "0.0.0.0",
  "corsAllowOrigins": ["https://opencodex.example.com"]
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions