macOS Keychain credential corruption: security -i 4096-byte line buffer causes OAuth token refresh failures
Summary
When MCP OAuth plugins (Slack, GitLab, etc.) store discovery metadata in the credential blob, the combined payload exceeds the macOS security -i 4096-byte line buffer limit, causing silent credential corruption. This results in persistent 401 "OAuth token has expired" errors and 400 responses from the refresh endpoint, requiring frequent /login to recover.
Root Cause
Claude Code stores OAuth credentials in macOS Keychain via the security CLI in interactive mode:
security -i <<< 'add-generic-password -U -a "USER" -s "Claude Code-credentials" -X "HEX_DATA"\n'
The security -i command has a hard 4096-byte line buffer limit. The command structure is:
| Component | Size |
|-----------|------|
| add-generic-password -U -a "USER" -s "Claude Code-credentials" -X " | ~67 bytes |
| Hex-encoded credential data | variable |
| "\n | 2 bytes |
Since hex encoding doubles the size, the maximum credential JSON payload is:
(4096 - 67 - 2) / 2 = 2013.5→ 2,013 bytes max (practically ~2,014 with alignment)
Credential Size Growth with MCP Plugins
MCP OAuth plugins store discovery metadata (authorization endpoints, token endpoints, supported grant types, etc.) in the same credential blob. Measured sizes:
| Configuration | Credential Size | Status |
|---------------|----------------|--------|
| claudeAiOauth only | ~467 bytes | OK |
| + Atlassian plugin | ~483 bytes | OK (minimal metadata) |
| + Slack plugin | ~2,343 bytes | OVER LIMIT |
| + GitLab plugin | ~4,361 bytes | WAY OVER |
Failure Chain
- User enables MCP plugins with OAuth (Slack, GitLab, etc.)
- Plugin OAuth discovery metadata is added to the credential JSON
- Credential exceeds 2,014 bytes →
security -iwrite silently fails (exit code 1) - Keychain retains old/truncated data; plaintext fallback (
.credentials.json) may or may not catch the write - On next token refresh:
POST /v1/oauth/tokenwithgrant_type=refresh_token→ 400 Bad Request (because the refreshed token was never persisted, the old revoked token is reused) - On next API call: cached access token expired → 401 "OAuth token has expired"
- User must
/loginevery few hours to get fresh tokens
Evidence
Experimental proof of the 4096-byte limit
# Size 2014 (command = 4096 bytes) → SUCCESS
security -i <<< "add-generic-password -U -a test_user -s test-svc -X \"$(python3 -c "print('41' * 2014)")\""
echo $? # 0
# Size 2015 (command = 4098 bytes) → FAILURE
security -i <<< "add-generic-password -U -a test_user -s test-svc -X \"$(python3 -c "print('41' * 2015)")\""
echo $? # 1
Debug log pattern (zero successful refreshes across 4,300+ sessions)
grep -c "status=400" debug_logs # Hundreds of refresh failures
grep -c "status=200" debug_logs # Zero successful refreshes
Every single token refresh attempt returned 400. Manual curl to the refresh endpoint with a valid token returned 200, confirming the server-side refresh works correctly.
Keychain "Failed to delete" errors
Every /login since enabling MCP plugins shows "Failed to delete keychain entry" in debug logs, suggesting the keychain entry is in a corrupted state that prevents normal deletion.
Suggested Fixes
Short-term
- Use
security add-generic-passworddirectly instead ofsecurity -i(avoids the line buffer limit entirely):
``bash``
security add-generic-password -U -a "USER" -s "SERVICE" -w "PASSWORD"
Or pass the hex data via a temp file/pipe instead of inline.
- Chunk the credential across multiple keychain entries if a single entry is too large.
Long-term
- Store MCP OAuth discovery metadata separately from the main credential blob. Discovery metadata is cacheable and doesn't need to be in the credential store.
- Add credential size validation before writing — log a warning and gracefully degrade (e.g., skip persisting non-essential metadata) rather than silently failing.
- Add write verification — read back after writing to ensure the credential was persisted correctly.
Environment
- macOS 15.4 (Darwin 25.3.0)
- Claude Code 2.1.56
- MCP plugins: Atlassian, Slack, GitLab (all with OAuth)
Workaround
Disable MCP plugins that use OAuth (Slack, GitLab, etc.) to keep the credential payload under the 2,014-byte limit, then /login to regenerate a clean credential.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗