Usage limit incorrectly shared across different accounts

Open 💬 2 comments Opened May 20, 2026 by 1144878989

What version of the Codex App are you using (From “About Codex” dialog)?

26.513.31313 (2867)

What subscription do you have?

plus & team

What platform is your computer?

_No response_

What issue are you seeing?

Environment

  • Platform: Codex / ChatGPT
  • Account A: ChatGPT Plus
  • Account B: ChatGPT Team
  • OS: macOS
  • Browser: Chrome
  • Region: Japan

Description

After reaching the 5-hour usage limit on Account A (Plus subscription), I switched to Account B (Team subscription). However, Account B immediately showed the same “5-hour limit reached” message, even though Account B had not been used at all during that period.

This makes it appear that the usage quota is being shared or cached across accounts unexpectedly.

Steps to Reproduce

  1. Log into Account A with a Plus subscription
  2. Use Codex until the 5-hour usage limit is reached
  3. Log out of Account A
  4. Log into Account B with a Team subscription
  5. Open Codex
  6. Observe that Account B also shows the “usage limit reached” message immediately

Expected Behavior

Account B should have its own independent usage quota and should not inherit the usage limit status from another account.

Actual Behavior

Account B incorrectly shows that the 5-hour limit has already been reached, despite having no usage during that time window.

Additional Context

  • The issue may be related to:
  • browser session caching
  • device-level rate limiting
  • IP-based quota enforcement
  • shared authentication/session state
  • Tried:
  • logging out/in
  • refreshing browser
  • switching accounts
  • Issue still persists

Impact

This prevents access to Codex on a separate paid Team account and blocks normal usage.

What steps can reproduce the bug?

Feedback ID: no-active-thread-019e45f5-b69e-7cc2-a5b2-2815a8fcd390

What is the expected behavior?

_No response_

Additional information

_No response_

View original on GitHub ↗

2 Comments

jshaofa-ui · 3 months ago

Solution: Usage Limit Incorrectly Shared Across Different Accounts

Issue: openai/codex #23706
Labels: bug, codex-web, auth, rate-limits, app
Created: 2026-05-20
Estimated Value: $2,000–$3,000

---

Executive Summary

When a user is authenticated with multiple ChatGPT accounts simultaneously (e.g., a personal ChatGPT Plus account and a Team workspace account), the Codex app-server incorrectly shares rate limit state across accounts. The root cause is that the AccountRequestProcessor maintains a single AuthManager instance that caches auth state globally, and the fetch_account_rate_limits() method does not properly scope rate limit queries to the requesting account's context. This causes users on Team accounts to see depleted rate limits from their personal Plus account, and vice versa.

The fix requires:

  1. Account-scoped rate limit caching — key rate limit snapshots by account_id
  2. Explicit account context in rate limit API calls — ensure ChatGPT-Account-ID header is always set when fetching rate limits
  3. Per-connection account isolation — ensure the app-server's WebSocket connections carry explicit account context for rate limit queries

---

Root Cause Analysis

Architecture Overview

The Codex app-server (codex-rs/app-server) manages authentication through a single AuthManager instance shared across all WebSocket connections:

AppServer
├── AuthManager (singleton)
│   ├── CachedAuth { auth: Option<CodexAuth> }  ← global cache
│   └── external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>
├── AccountRequestProcessor
│   └── auth_manager: Arc<AuthManager>          ← shared reference
└── Connection A (WebSocket) ──┐
   Connection B (WebSocket) ───┤──→ all use same AuthManager
   Connection C (WebSocket) ──┘

Bug Location

File: codex-rs/app-server/src/request_processors/account_processor.rs

Method: fetch_account_rate_limits() (lines ~878–920)

async fn fetch_account_rate_limits(
    &self,
) -> Result<(CoreRateLimitSnapshot, HashMap<String, CoreRateLimitSnapshot>), JSONRPCErrorError> {
    let Some(auth) = self.auth_manager.auth().await else {
        return Err(invalid_request("codex account authentication required..."));
    };

    // BUG: BackendClient is constructed from auth, but the auth_manager.auth() 
    // returns the GLOBAL cached auth — not the account-specific auth for this connection.
    let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
        .map_err(...)?;

    let snapshots = client.get_rate_limits_many().await...
}

Detailed Analysis

1. Global Auth Cache Without Account Isolation

The AuthManager (in codex-rs/login/src/auth/manager.rs) maintains a single CachedAuth in an RwLock:

pub struct AuthManager {
    codex_home: PathBuf,
    inner: RwLock<CachedAuth>,  // ← single global auth cache
    // ...
}

When auth() or auth_cached() is called, it returns the globally cached auth. In a multi-account scenario where a user might be connected with different accounts in different browser tabs/connections, this global cache means:

  • Connection A (Account: Plus) → auth_manager.auth() returns Plus auth
  • Connection B (Account: Team) → auth_manager.auth() returns Plus auth (stale!)
2. Missing Account Context in Rate Limit Queries

The BackendClient::from_auth() method creates an auth provider from CodexAuth, which includes the account_id. However, when the auth comes from the global cache (which may be stale for a specific connection), the ChatGPT-Account-ID header in rate limit requests points to the wrong account.

From codex-rs/model-provider/src/bearer_auth_provider.rs:

impl AuthProvider for BearerAuthProvider {
    fn add_auth_headers(&self, headers: &mut HeaderMap) {
        // ...
        if let Some(account_id) = self.account_id.as_ref() {
            let _ = headers.insert("ChatGPT-Account-ID", header);
        }
    }
}

The account_id is derived from CodexAuth::get_account_id(), which reads from the cached token data. If the cache is stale, the wrong account_id is sent.

3. No Per-Connection Account Context

The AccountRequestProcessor does not track which account a specific WebSocket connection is authenticated as. The fetch_account_rate_limits() method has no account_id parameter — it blindly uses the global auth:

pub(crate) async fn get_account_rate_limits(
    &self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
    self.get_account_rate_limits_response()
        .await
        .map(|response| Some(response.into()))
}

Compare with get_account(params: GetAccountParams) which at least accepts parameters, though it still uses the global auth.

4. Rate Limit State Not Account-Scoped

Even if the correct account is used for the API call, the rate limit snapshots are not cached per-account. The RateLimitSnapshot struct (from codex-rs/codex-api/src/rate_limits.rs) contains limit_id, plan_type, primary, secondary, and credits — but no account_id field. This means:

  • Rate limit responses from different accounts could be conflated
  • No way to distinguish "Plus account has 80% used" from "Team account has 20% used"

Impact

  • User-facing: Users see incorrect rate limit warnings ("You've reached your usage limit") when switching between accounts
  • Data integrity: Rate limit state from one account pollutes another
  • Business impact: Team account users may be blocked from using Codex because their personal account's limits are shown

---

Proposed Fix

Fix 1: Add Account-Scoped Rate Limit Caching

File: codex-rs/app-server/src/request_processors/account_processor.rs

Add an account-scoped rate limit cache to AccountRequestProcessor:

use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

/// Rate limit data scoped to a specific account.
#[derive(Clone, Debug)]
struct AccountRateLimitData {
    snapshot: CoreRateLimitSnapshot,
    by_limit_id: HashMap<String, CoreRateLimitSnapshot>,
    fetched_at: std::time::Instant,
}

#[derive(Clone)]
pub(crate) struct AccountRequestProcessor {
    auth_manager: Arc<AuthManager>,
    thread_manager: Arc<ThreadManager>,
    outgoing: Arc<OutgoingMessageSender>,
    config: Arc<Config>,
    config_manager: ConfigManager,
    active_login: Arc<Mutex<Option<ActiveLogin>>>,
    // NEW: Account-scoped rate limit cache
    rate_limit_cache: Arc<RwLock<HashMap<String, AccountRateLimitData>>>,
}

Fix 2: Accept Account ID in Rate Limit Queries

File: codex-rs/app-server-protocol/src/protocol/v2.rs (or equivalent)

Update the get_account_rate_limits method to accept an optional account_id parameter:

pub(crate) async fn get_account_rate_limits(
    &self,
    account_id: Option<String>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
    self.get_account_rate_limits_response(account_id)
        .await
        .map(|response| Some(response.into()))
}

Fix 3: Scope Rate Limit Fetching to Specific Account

File: codex-rs/app-server/src/request_processors/account_processor.rs

Rewrite fetch_account_rate_limits to properly scope to the target account:

async fn fetch_account_rate_limits(
    &self,
    target_account_id: Option<&str>,
) -> Result<
    (CoreRateLimitSnapshot, HashMap<String, CoreRateLimitSnapshot>),
    JSONRPCErrorError,
> {
    // Check cache first (account-scoped)
    if let Some(account_id) = target_account_id {
        let cache = self.rate_limit_cache.read().await;
        if let Some(cached) = cache.get(account_id) {
            // Cache valid for 30 seconds
            if cached.fetched_at.elapsed() < Duration::from_secs(30) {
                return Ok((cached.snapshot.clone(), cached.by_limit_id.clone()));
            }
        }
    }

    // Resolve auth for the specific account
    let auth = self.resolve_auth_for_account(target_account_id).await?;

    if !auth.uses_codex_backend() {
        return Err(invalid_request(
            "chatgpt authentication required to read rate limits",
        ));
    }

    // Build backend client with explicit account context
    let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
        .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;

    let snapshots = client
        .get_rate_limits_many()
        .await
        .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;

    if snapshots.is_empty() {
        return Err(internal_error(
            "failed to fetch codex rate limits: no snapshots returned",
        ));
    }

    let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
        .iter()
        .cloned()
        .map(|snapshot| {
            let limit_id = snapshot
                .limit_id
                .clone()
                .unwrap_or_else(|| "codex".to_string());
            (limit_id, snapshot)
        })
        .collect();

    let primary = snapshots
        .iter()
        .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
        .cloned()
        .unwrap_or_else(|| snapshots[0].clone());

    // Update account-scoped cache
    if let Some(account_id) = target_account_id {
        let mut cache = self.rate_limit_cache.write().await;
        cache.insert(
            account_id.to_string(),
            AccountRateLimitData {
                snapshot: primary.clone(),
                by_limit_id: rate_limits_by_limit_id.clone(),
                fetched_at: std::time::Instant::now(),
            },
        );
    }

    Ok((primary, rate_limits_by_limit_id))
}

/// Resolve auth for a specific account, falling back to current auth if no account specified.
async fn resolve_auth_for_account(
    &self,
    target_account_id: Option<&str>,
) -> Result<CodexAuth, JSONRPCErrorError> {
    match target_account_id {
        Some(account_id) => {
            // Try to find auth matching the target account
            let current_auth = self.auth_manager.auth().await;
            
            match current_auth {
                Some(ref auth) => {
                    let current_account_id = auth.get_account_id();
                    if current_account_id.as_deref() == Some(account_id) {
                        return Ok(auth.clone());
                    }
                    // Account mismatch — this connection is authenticated as a different account
                    return Err(invalid_request(format!(
                        "rate limits can only be fetched for the currently authenticated account. \
                         Requested: {account_id}, authenticated as: {:?}",
                        current_account_id.unwrap_or_else(|| "unknown".to_string())
                    )));
                }
                None => Err(invalid_request(
                    "codex account authentication required to read rate limits",
                )),
            }
        }
        None => {
            self.auth_manager.auth().await.ok_or_else(|| {
                invalid_request("codex account authentication required to read rate limits")
            })
        }
    }
}

Fix 4: Ensure ChatGPT-Account-ID Header in All Rate Limit Calls

File: codex-rs/backend-client/src/client.rs

Verify that BackendClient::from_auth() correctly propagates the account ID:

pub fn from_auth(base_url: impl Into<String>, auth: &CodexAuth) -> Result<Self> {
    let account_id = auth.get_account_id();
    let is_fedramp = auth.is_fedramp_account();
    
    let mut client = Self::new(base_url)?
        .with_user_agent(get_codex_user_agent())
        .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth));
    
    // Ensure account ID is explicitly set on the client
    if let Some(ref id) = account_id {
        client = client.with_chatgpt_account_id(id.clone());
    }
    if is_fedramp {
        client = client.with_fedramp_routing_header();
    }
    
    Ok(client)
}

Fix 5: Invalidate Rate Limit Cache on Account Switch

File: codex-rs/app-server/src/request_processors/account_processor.rs

Clear the rate limit cache for the old account when switching:

async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
    // Invalidate rate limit cache for the previous account
    if let Some(prev_auth) = self.auth_manager.auth_cached() {
        if let Some(prev_account_id) = prev_auth.get_account_id() {
            let mut cache = self.rate_limit_cache.write().await;
            cache.remove(&prev_account_id);
        }
    }
    
    // ... rest of existing method
}

---

Test Plan

Unit Tests

  1. Test: Rate limits are scoped to account
  • Mock two accounts (Plus and Team) with different rate limits
  • Verify fetch_account_rate_limits(Some("account_plus")) returns Plus limits
  • Verify fetch_account_rate_limits(Some("account_team")) returns Team limits
  1. Test: Cache invalidation on account switch
  • Fetch rate limits for Account A
  • Switch to Account B
  • Verify Account A's rate limits are removed from cache
  1. Test: Stale cache refresh
  • Set cache TTL to 1 second
  • Fetch rate limits, wait 2 seconds, fetch again
  • Verify fresh API call was made
  1. Test: Account mismatch error
  • Authenticate as Account A
  • Request rate limits for Account B
  • Verify appropriate error is returned

Integration Tests

  1. Test: Multi-connection rate limit isolation
  • Simulate two WebSocket connections with different accounts
  • Verify each connection receives correct rate limits
  1. Test: Rate limit header propagation
  • Capture HTTP requests to /wham/usage or /api/codex/usage
  • Verify ChatGPT-Account-ID header matches the authenticated account

E2E Tests

  1. Test: Account switching in web UI
  • Log in with Account A (Plus)
  • Check rate limits display correctly
  • Switch to Account B (Team)
  • Verify rate limits update to Team limits (not Plus)

Regression Tests

  1. Test: Single account still works
  • Verify existing single-account behavior is unchanged
  • Rate limits display correctly without account_id parameter

---

Impact Assessment

Risk Level: Medium

  • Scope: Changes affect rate limit display and account switching
  • Backward Compatibility: Adding optional account_id parameter is backward compatible
  • Performance: Minimal — adds a small in-memory cache per account

Files Modified

| File | Change |
|------|--------|
| codex-rs/app-server/src/request_processors/account_processor.rs | Add rate limit cache, accept account_id, scope queries |
| codex-rs/backend-client/src/client.rs | Ensure account ID propagation in from_auth() |
| codex-rs/app-server-protocol/src/protocol/ | Add optional account_id to rate limit params (if needed) |

Dependencies

  • No new dependencies required
  • Uses existing tokio::sync::RwLock and HashMap

Migration Path

  1. Deploy with backward-compatible optional account_id parameter
  2. Frontend can gradually adopt explicit account context
  3. Default behavior (no account_id) remains unchanged for single-account users

Rollback Plan

  • Changes are additive (new parameter, new cache)
  • If issues arise, revert to using global auth without account scoping
  • Cache TTL ensures stale data auto-expires

---

Additional Considerations

Why This Bug Occurs

The AuthManager was designed as a singleton because the CLI tool typically runs with a single account. However, the app-server (used by the web/desktop app) serves multiple connections that may represent different accounts. The rate limiting code path inherited the CLI's single-account assumptions without accounting for multi-connection scenarios.

Alternative Approaches Considered

  1. Per-connection AuthManager — Would require significant refactoring of the app-server architecture. Higher risk, more invasive.
  1. Server-side account scoping — Would require OpenAI backend changes. Not feasible for a client-side fix.
  1. This approach (account-scoped cache) — Minimal changes, backward compatible, addresses the root cause directly.

Monitoring Recommendations

  • Add telemetry for rate limit cache hit/miss rates per account
  • Log account mismatches in rate limit queries
  • Alert on frequent rate limit fetch failures

---

Estimated Effort

| Phase | Effort |
|-------|--------|
| Implementation | 2–3 days |
| Unit tests | 1 day |
| Integration/E2E tests | 1–2 days |
| Code review & iteration | 1 day |
| Total | 5–7 days |

---

Document generated: 2026-05-21
Analysis based on openai/codex main branch as of 2026-05-20

AdrianNO1 · 2 months ago

I have this issue too. #16894 is a potential duplicate.