Skip to content

Usage limit incorrectly shared across different accounts #23706

Open

Description

@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

Activity

  1. added
    appIssues related to the Codex desktop app
    on May 20, 2026
  2. added
    bugSomething isn't working
    codex-webIssues related to Codex Web
    authIssues related to authentication and accounts
    rate-limitsIssues related to rate limits, quotas, and token usage reporting
    on May 20, 2026
  3. jshaofa-ui commented on May 20, 2026

    @jshaofa-ui

    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
    2. 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
    3. Test: Stale cache refresh

      • Set cache TTL to 1 second
      • Fetch rate limits, wait 2 seconds, fetch again
      • Verify fresh API call was made
    4. 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
    2. 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.

    2. Server-side account scoping — Would require OpenAI backend changes. Not feasible for a client-side fix.

    3. 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

  4. AdrianNO1 commented on Jun 11, 2026

    @AdrianNO1

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appauthIssues related to authentication and accountsbugSomething isn't workingcodex-webIssues related to Codex Webrate-limitsIssues related to rate limits, quotas, and token usage reporting

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions