macOS Keychain: security -i 4096-byte line buffer causes credential corruption with MCP OAuth plugins
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
- User enables MCP plugins with OAuth (e.g., Slack, GitLab)
- Credential blob grows beyond ~2013 bytes
security -ikeychain write fails silently (or truncates)- Claude Code falls back to plaintext
.credentials.jsonfile — this works initially - On token refresh: new access+refresh tokens are obtained from server, but keychain write fails again
- The fallback file may also become stale or get cleaned up
- Next session reads corrupted/stale credentials → 401 OAuth token expired
- Refresh attempt uses revoked token (due to server-side refresh token rotation) → 400 Bad Request
- User must
/loginagain, but the cycle repeats within hours
Evidence from Debug Logs
Across ~4300 session debug logs:
- Zero successful token refreshes —
platform.claude.com/v1/oauth/token,status=400every time - "Failed to delete keychain entry" at every
/loginsince 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
- Use
securityCLI arguments instead of stdin — Pass credentials via command-line args withsecurity add-generic-password -U -a ACCT -s SVC -w PASSWORD(but note:-whas its own escaping issues with special characters)
- Use the Keychain Services API directly — Via a native module or FFI, bypass the
securityCLI entirely. This removes all buffer limits.
- Split storage — Store MCP OAuth tokens in separate keychain entries (one per plugin) instead of a single monolithic blob. This keeps each entry small.
- Limit stored metadata — Don't persist the full OAuth discovery metadata (
authorizationServerMetadata) in the credential blob. Re-discover it at connection time instead.
- Chunked stdin writes — Split the
security -icommand 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
- Install Claude Code on macOS
- Log in via OAuth (
/login) - Enable multiple MCP plugins with OAuth:
slack@claude-plugins-official,gitlab@claude-plugins-official - Authenticate with the MCP plugins
- Wait for the OAuth access token to expire (~8 hours)
- Open a new terminal and run
claude - Observe: 401 "OAuth token expired" error, automatic refresh fails with 400
This issue has 6 comments on GitHub. Read the full discussion on GitHub ↗