macOS Keychain: security -i 4096-byte line buffer causes credential corruption with MCP OAuth plugins

Resolved 💬 6 comments Opened Feb 26, 2026 by jean-humann Closed Mar 1, 2026

Bug Description

When MCP OAuth plugins (e.g., Slack, GitLab) store their OAuth discovery metadata in the Claude Code credential blob, the total serialized credential can exceed a hard 4096-byte line buffer limit in macOS's security -i command, causing silent credential truncation/corruption. This leads to persistent 401 "OAuth token expired" errors that cannot be resolved by token refresh.

Root Cause

Claude Code stores all OAuth credentials (its own + MCP plugin tokens) in a single macOS Keychain entry via the security CLI's interactive mode (security -i). The write command is piped via stdin as:

add-generic-password -U -a "USER" -s "SERVICE" -X "HEX_ENCODED_DATA"\n

macOS security -i has a 4096-byte line buffer limit. When the stdin command exceeds 4096 bytes, the write either fails silently (exit code 1) or truncates the data, corrupting the stored JSON.

Byte Budget Calculation

| Component | Size |
|-----------|------|
| Command prefix (add-generic-password -U -a "USER" -s "Claude Code-credentials" -X ") | ~67 bytes |
| Hex-encoded credential data (2 bytes per source byte) | 2 × N |
| Suffix ("\n) | 2 bytes |
| Total | 69 + 2N ≤ 4096 |
| Max credential payload | (4096 - 69) / 2 = 2013.5 → 2013 bytes |

Experimental Verification

Tested keychain writes with payloads from 2000 to 2100 bytes:

| Payload Size | Command Size | Result |
|-------------|-------------|--------|
| 2013 bytes | 4095 bytes | ✅ Success |
| 2014 bytes | 4097 bytes | ❌ Fails (exit code 1) |

The exact boundary is 4096 bytes total command length — matching the classic LINE_MAX / BUFSIZ constant in C stdio.

Credential Size by Plugin Combination

| Configuration | Credential Size | Status |
|--------------|----------------|--------|
| claudeAiOauth only | ~467 bytes | ✅ OK |
| + Atlassian MCP | ~483 bytes | ✅ OK |
| + Slack MCP | ~2,343 bytes | ❌ OVER LIMIT |
| + Slack + GitLab + Atlassian MCP | ~4,361 bytes | ❌ WAY OVER |

The credential grows large because MCP OAuth plugins store full OAuth discovery metadata (authorization endpoints, token endpoints, issuer, supported grant types, etc.) inside the same credential blob.

Failure Cascade

  1. User enables MCP plugins with OAuth (e.g., Slack, GitLab)
  2. Credential blob grows beyond ~2013 bytes
  3. security -i keychain write fails silently (or truncates)
  4. Claude Code falls back to plaintext .credentials.json file — this works initially
  5. On token refresh: new access+refresh tokens are obtained from server, but keychain write fails again
  6. The fallback file may also become stale or get cleaned up
  7. Next session reads corrupted/stale credentials → 401 OAuth token expired
  8. Refresh attempt uses revoked token (due to server-side refresh token rotation) → 400 Bad Request
  9. User must /login again, but the cycle repeats within hours

Evidence from Debug Logs

Across ~4300 session debug logs:

  • Zero successful token refreshesplatform.claude.com/v1/oauth/token,status=400 every time
  • "Failed to delete keychain entry" at every /login since the MCP plugins were added
  • Server-side refresh works correctly (confirmed via direct curl to token endpoint)

Affected Code

The keychain write in the ge_ (keychain backend) update() method:

update(T) {
    // ...
    let B = Buffer.from(_, "utf-8").toString("hex"),
        D = `add-generic-password -U -a "${A}" -s "${R}" -X "${B}"\n`;
    if (b3T("security", ["-i"], { input: D, ... }).exitCode !== 0)
        return { success: false };
    // ...
}

Suggested Fixes

  1. Use security CLI arguments instead of stdin — Pass credentials via command-line args with security add-generic-password -U -a ACCT -s SVC -w PASSWORD (but note: -w has its own escaping issues with special characters)
  1. Use the Keychain Services API directly — Via a native module or FFI, bypass the security CLI entirely. This removes all buffer limits.
  1. Split storage — Store MCP OAuth tokens in separate keychain entries (one per plugin) instead of a single monolithic blob. This keeps each entry small.
  1. Limit stored metadata — Don't persist the full OAuth discovery metadata (authorizationServerMetadata) in the credential blob. Re-discover it at connection time instead.
  1. Chunked stdin writes — Split the security -i command across multiple lines if the single-line approach exceeds the buffer (the interactive mode supports multiple commands per session).

Environment

  • OS: macOS 15.4 (Darwin 25.3.0)
  • Claude Code: 2.1.56 (confirmed), likely affects all versions using security -i
  • Note: v2.1.59's "Fixed MCP OAuth token refresh race condition" fix addresses a different issue (multi-instance race), not this buffer limit bug

Workaround

Disable MCP plugins that use OAuth (Slack, GitLab, etc.) to keep the credential blob under ~2013 bytes. Atlassian alone is small enough to fit.

Reproduction Steps

  1. Install Claude Code on macOS
  2. Log in via OAuth (/login)
  3. Enable multiple MCP plugins with OAuth: slack@claude-plugins-official, gitlab@claude-plugins-official
  4. Authenticate with the MCP plugins
  5. Wait for the OAuth access token to expire (~8 hours)
  6. Open a new terminal and run claude
  7. Observe: 401 "OAuth token expired" error, automatic refresh fails with 400

View original on GitHub ↗

This issue has 6 comments on GitHub. Read the full discussion on GitHub ↗