Skip to content

feat(cli): store the lite login credential in the OS keychain - #37566

Merged
mateo-berri merged 26 commits into
litellm_internal_stagingfrom
litellm_cli_refresh_tokens
Aug 20, 2026
Merged

feat(cli): store the lite login credential in the OS keychain#37566
mateo-berri merged 26 commits into
litellm_internal_stagingfrom
litellm_cli_refresh_tokens

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • lite login writes the proxy credential to a cleartext file
  • Any process running as the user can read it
  • ~/.litellm was created traversable by other accounts

How it solves it:

  • The credential now lives in the OS keychain
  • The token file keeps only non-secret metadata
  • Headless boxes fall back to a private file
  • Credentials from an older lite keep working
  • A --pkce refresh token is not moved yet, see Caveats

User Flow

Before: a developer signs in to their company gateway, and the credential that grants their whole role sits in a cleartext file, so any program running as them can lift it and spend against their account

  1. The developer runs lite --base-url https://litellm-domain login, finishes SSO in the browser, and sees Login successful!
  2. They run lite models list and get their model list back
  3. They send POST https://litellm-domain/v1/chat/completions through the CLI and get a 200 with a completion
  4. They check the directory holding the credential with ls -ld ~/.litellm and see drwxr-xr-x, traversable by other accounts on the box
  5. They run cat ~/.litellm/token.json and read the credential itself in the clear, in a key field next to their gateway URL and user id
  6. Any other program running as that developer, a build script or an editor extension, reads that same file and sends POST https://litellm-domain/v1/chat/completions with the copied credential, getting a 200 completion billed to the developer and carrying their role
  7. Copying that one file to another machine reproduces the developer's access there, and nothing on the machine asks for consent

After: the same login puts the credential in the OS keychain, so the file no longer carries it and the OS gates who may read it

  1. The developer runs lite --base-url https://litellm-domain login, finishes SSO in the browser, and sees Login successful! followed by Credential stored in your OS keychain.
  2. They run lite models list and get their model list back
  3. They send POST https://litellm-domain/v1/chat/completions through the CLI and get a 200 with a completion
  4. They check the directory with ls -ld ~/.litellm and see drwx------, reachable only by them
  5. They run cat ~/.litellm/token.json and see only their gateway URL, user id, role, and sign-in time; there is no credential in the file
  6. A build script or editor extension reading ~/.litellm/token.json finds no credential, and the request it sends without one comes back 401
  7. They run security find-generic-password -s litellm-cli -a credential -w on macOS, or open Credential Manager on Windows, and the credential is there, held by the OS keychain
  8. On a headless Linux box with no keychain available, the same login prints that it stored the credential in the file instead, and everything keeps working, with the file readable only by the developer
  9. A developer whose last sign-in was on an older lite is not asked to sign in again: their existing credential still authenticates on the next command, moves into the keychain, and is wiped from the file
  10. lite logout clears the keychain entry as well as the file, and warns rather than reporting success if the keychain refuses to release it
  11. On a box where ~/.litellm will accept no new file, lite logout still prints Logged out successfully, and cat ~/.litellm/token.json shows the credential gone, instead of handing the developer instructions to delete the file themselves
  12. When the keychain holds the credential but will not release it, lite whoami opens with Signed in, but the credential cannot be read rather than Authenticated, so the developer knows why their next request fails
  13. Setting LITELLM_PROXY_API_KEY or passing --api-key still wins over the stored credential, unchanged
  14. On a box where ~/.litellm/token.json cannot be replaced, a fresh lite login still takes effect: the CLI says the file could not be replaced, and the next command authenticates with the credential just minted rather than the superseded one the file still names, and that still holds when the machine's clock has moved backwards since the previous sign-in
  15. A developer who signs in with lite login --pkce instead gets the same result: Credential stored in your OS keychain., and no key in ~/.litellm/token.json
  16. A day later their key has expired, and the next lite command renews it without asking; the renewed key goes into the keychain too, so cat ~/.litellm/token.json still shows no key
  17. lite logout on that credential sends POST https://litellm-domain/revoke first, so the refresh token stops working on the server as well as disappearing from the machine
  18. What a --pkce sign-in does still leave in ~/.litellm/token.json is the refresh token, so a build script reading that file can trade it at POST https://litellm-domain/token for a key that works; closing that is the follow-up named under Caveats

Before, a second program running as that developer could reach every route their role allows, on any machine the file was copied to. After, a lite login credential leaves it nothing to lift and the OS gates the keychain entry; a --pkce credential leaves it the refresh token, which is narrower but not yet nothing

Relevant issues

Linear ticket

Resolves LIT-5855

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Every leg below is the shipped lite CLI driven end to end against a live proxy, real Postgres, and real Anthropic calls on anthropic/claude-haiku-4-5. No mocks, no pytest, no DB queries, no LiteLLM internals imported as evidence. The browser half of SSO is stood in for by curl against the proxy's own /sso/key/generate and /sso/cli/complete/<login_id> endpoints, and the browser half of PKCE by curl against /authorize and /authorize/complete, with a local OIDC stub as the identity provider. Everything else is what an end user types. lite login prints only the first 20 characters of the credential it minted, and every credential in these logs is truncated further to 12.

  • Before: b0911585d7d8fbb571b59431ecd1b2b21f1b781c, which is this merge's second parent, so it is this branch with the PR taken away. Proxy on port 9715
  • After, PKCE, the refresh-token gap, and the edge cases: all at the tip 5f7c0e1e49c70b030aad11d38dcf82b1f0149a0c. Proxy on port 9714
  • Both proxies share one namespaced Postgres. Each leg prints its own pwd and the litellm.__file__ it loaded, so neither side can be the other tree by accident

Before, at b0911585d7d8fbb571b59431ecd1b2b21f1b781c

A sign-in, then a second process running as the same user reading the credential straight out of ~/.litellm/token.json and spending it on a real provider call. ~/.litellm is drwxr-xr-x here, and the keychain is never touched.

The full before leg
########## WHICH TREE ##########
$ pwd
/private/tmp
$ git -C <before worktree> rev-parse HEAD
b0911585d7d8fbb571b59431ecd1b2b21f1b781c
$ python -c 'import litellm; print(litellm.__file__)'
/Users/mateo/Development/litellm-worktrees/lit5855_qa_before/litellm/__init__.py
$ python -c 'import litellm.litellm_core_utils.cli_keyring'
ModuleNotFoundError: No module named 'litellm.litellm_core_utils.cli_keyring'

########## 1. SIGN IN ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- CLI stdout ---
Opening browser to: http://localhost:9715/sso/key/generate?source=litellm-cli&key=cli-kTlceTIaheDrBb8toEPSVP1J57LOqgsJ
Please complete the SSO authentication in your browser...
Verification code: J6TY-65NC
Session ID: cli-kTlceTIaheDrBb8toEPSVP1J57LOqgsJ
Waiting for authentication...
Still waiting for authentication...

Login successful!
JWT Token: Dzexaiqf2AKzvrkcMW9t...
You can now use the CLI without specifying --api-key

============================================================
Available commands:
  login                Authenticate with the LiteLLM proxy server
  logout               Clear stored authentication
  whoami               Show current authentication status
  models               Manage and view model configurations
  credentials          Manage API credentials
  chat                 Interactive streaming chat with models
  http                 Make HTTP requests to the proxy
  keys                 Manage API keys
  teams                Manage teams and team assignments
  users                Manage users
  claude               Run Claude Code through your LiteLLM proxy
  codex                Run Codex through your LiteLLM proxy
  opencode             Run OpenCode through your LiteLLM proxy
  version              Show version information
  help                 Show this help message
  quit                 Exit the interactive session

--- driver stderr ---
DRIVER: browser half for login_id=cli-kTlceTIaheDr...REDACTED... user_code=J6TY-65NC
DRIVER: POST /sso/cli/complete/cli-kTlceTIaheDr...REDACTED... -> HTTP 200

########## 2. WHERE THE CREDENTIAL LIVES ##########
$ ls -la $HOME/.litellm
total 8
drwxr-xr-x@ 3 mateo  wheel    96 Aug 20 11:04 .
drwxr-xr-x@ 4 mateo  wheel   128 Aug 20 11:04 ..
-rw-------@ 1 mateo  wheel  1012 Aug 20 11:04 token.json
$ stat -f '%Sp %N' $HOME/.litellm $HOME/.litellm/token.json
drwxr-xr-x $HOME/.litellm
-rw------- $HOME/.litellm/token.json
$ cat $HOME/.litellm/token.json
  base_url:           'http://localhost:9715'
  key:                'Dzexaiqf2AKz...REDACTED...'
  user_id:            'qa5855_run8_user'
  user_email:         'unknown'
  user_role:          'cli'
  auth_header_name:   'Authorization'
  jwt_token:          ''
  timestamp:          1787249094.077546
$ grep -c '"key"' $HOME/.litellm/token.json
1

########## 3. THE OS KEYCHAIN ##########
$ security find-generic-password -s litellm-cli -a credential
security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.

########## 4. THE SCAVENGER ##########
$ python scavenger.py
everything on disk that could be a credential: {"base_url": "http://localhost:9715", "key": "Dzexaiqf2AKz...REDACTED...", "user_id": "qa5855_run8_user", "user_email": "unknown", "user_role": "cli", "auth_header_name": "Authorization"}
secret fields found: ['key']
bearer token it can build: 'Dzexaiqf2AKz...REDACTED...'
HTTP 200 -- THE STOLEN CREDENTIAL WORKED

########## 5. A REAL PROVIDER CALL ##########
"content": "QA5855 BEFORE b0911585d7 file credential authenticated this call"

########## 6. WHOAMI ##########
$ lite whoami
Authenticated
User Email: unknown
User ID: qa5855_run8_user
User Role: cli
Token age: 0.0 hours

########## 7. LOGOUT ##########
$ lite logout
Logged out successfully. Authentication token cleared.
$ ls -la $HOME/.litellm
total 0
drwxr-xr-x@ 2 mateo  wheel   64 Aug 20 11:05 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:04 ..
$ lite whoami
Not authenticated. Run 'lite login' to authenticate.

After, at 5f7c0e1e49c70b030aad11d38dcf82b1f0149a0c

The same sign-in. token.json keeps the gateway URL, user id, role, header name, and timestamp, and no credential. ~/.litellm is now drwx------. The same scavenger finds nothing to lift and its request comes back 401, while the CLI's own request goes through.

The full after leg
########## WHICH TREE ##########
$ pwd
/private/tmp
$ git -C <after worktree> rev-parse HEAD
5f7c0e1e49c70b030aad11d38dcf82b1f0149a0c
$ python -c 'import litellm; print(litellm.__file__)'
/Users/mateo/Development/litellm-worktrees/lit5855_qa_after/litellm/__init__.py
$ python -c 'import litellm.litellm_core_utils.cli_keyring as m; print(m.__file__)'
/Users/mateo/Development/litellm-worktrees/lit5855_qa_after/litellm/litellm_core_utils/cli_keyring.py

########## 1. SIGN IN ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- CLI stdout ---
Opening browser to: http://localhost:9714/sso/key/generate?source=litellm-cli&key=cli-5LlSjVqtbnwdkF9woik_LVqQC-i6EJ_E
Please complete the SSO authentication in your browser...
Verification code: 8LKQ-PKQU
Session ID: cli-5LlSjVqtbnwdkF9woik_LVqQC-i6EJ_E
Waiting for authentication...
Still waiting for authentication...

Login successful!
JWT Token: cmKJlZpzqEmuZ0OHfzeC...
Credential stored in your OS keychain.
You can now use the CLI without specifying --api-key

============================================================
Available commands:
  login                Authenticate with the LiteLLM proxy server
  logout               Clear stored authentication
  whoami               Show current authentication status
  models               Manage and view model configurations
  credentials          Manage API credentials
  chat                 Interactive streaming chat with models
  http                 Make HTTP requests to the proxy
  keys                 Manage API keys
  teams                Manage teams and team assignments
  users                Manage users
  claude               Run Claude Code through your LiteLLM proxy
  codex                Run Codex through your LiteLLM proxy
  opencode             Run OpenCode through your LiteLLM proxy
  version              Show version information
  help                 Show this help message
  quit                 Exit the interactive session

--- driver stderr ---
DRIVER: browser half for login_id=cli-5LlSjVqtbnwd...REDACTED... user_code=8LKQ-PKQU
DRIVER: POST /sso/cli/complete/cli-5LlSjVqtbnwd...REDACTED... -> HTTP 200

########## 2. WHERE THE CREDENTIAL LIVES ##########
$ ls -la $HOME/.litellm
total 8
drwx------@ 3 mateo  wheel   96 Aug 20 11:04 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:04 ..
-rw-------@ 1 mateo  wheel  215 Aug 20 11:04 token.json
$ stat -f '%Sp %N' $HOME/.litellm $HOME/.litellm/token.json
drwx------ $HOME/.litellm
-rw------- $HOME/.litellm/token.json
$ cat $HOME/.litellm/token.json
  base_url:           'http://localhost:9714'
  user_id:            'qa5855_run8_user'
  user_email:         'unknown'
  user_role:          'cli'
  auth_header_name:   'Authorization'
  jwt_token:          ''
  timestamp:          1787249082.815923
$ grep -c '"key"' $HOME/.litellm/token.json
0
0

########## 3. THE KEYCHAIN HOLDS THE SECRET ##########
$ security find-generic-password -s litellm-cli -a credential
keychain: "/Users/mateo/Library/Keychains/login.keychain-db"
version: 512
class: "genp"
    "acct"<blob>="credential"
    "svce"<blob>="litellm-cli"
--- what the CLI serves, and what the keychain blob holds (read in-process, no ACL prompt) ---
keychain blob key:       cmKJlZpzqEmu...REDACTED...
blob base_url:           http://localhost:9714
blob timestamp:          1787249082.815923
blob has refresh_token:  False
preflight left over:     NO

########## 4. THE SCAVENGER ##########
$ python scavenger.py
everything on disk that could be a credential: {"base_url": "http://localhost:9714", "user_id": "qa5855_run8_user", "user_email": "unknown", "user_role": "cli", "auth_header_name": "Authorization"}
secret fields found: NONE
bearer token it can build: ''
HTTP 401 Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.

########## 5. A REAL PROVIDER CALL ##########
$ lite http request POST /v1/chat/completions -j ...
"content": "QA5855 AFTER 5f7c0e1e49 keychain credential authenticated this call"

########## 6. WHOAMI ##########
$ lite whoami
Authenticated
User Email: unknown
User ID: qa5855_run8_user
User Role: cli
Token age: 0.0 hours

########## 7. LOGOUT ##########
$ lite logout
Logged out successfully. Authentication token cleared.
$ ls -la $HOME/.litellm
total 0
drwx------@ 2 mateo  wheel   64 Aug 20 11:04 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:04 ..
$ security find-generic-password -s litellm-cli -a credential
security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
$ lite whoami
Not authenticated. Run 'lite login' to authenticate.

The PKCE flow that arrived in the merge, at the same tip

The base branch grew lite login --pkce while this PR was in review, and it renews the key behind the user's back. That renewal is a second write path into credential storage, so it gets its own leg: sign in with --pkce, expire the stored credential by hand, run one ordinary command, and see where the renewed key lands. It lands in the keychain, not in token.json. Logout then revokes the refresh token on the proxy for real, and replaying it afterwards is refused.

The full PKCE leg, including the silent renewal and the revocation
########## P1. PKCE SIGN-IN AT THE MERGE TIP ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- CLI stdout ---
Opening browser to: http://localhost:9714/authorize?response_type=code&client_id=llm_dcrc_w7-...REDACTED...%3D&redirect_uri=http%3A%2F%2F127.0.0.1%3A52991%2Fcallback&state=-6DIHEvAjiOk...REDACTED...&code_challenge=vDeLxI_LzbuA...REDACTED...&code_challenge_method=S256&resource=http%3A%2F%2Flocalhost%3A9714
Approve the sign-in in your browser. Waiting...

Login successful!
JWT Token: jjUpmtXRZmH_V5eN_fGt...
Credential stored in your OS keychain.
You can now use the CLI without specifying --api-key

============================================================
Available commands:
  login                Authenticate with the LiteLLM proxy server
  logout               Clear stored authentication
  whoami               Show current authentication status
  models               Manage and view model configurations
  credentials          Manage API credentials
  chat                 Interactive streaming chat with models
  http                 Make HTTP requests to the proxy
  keys                 Manage API keys
  teams                Manage teams and team assignments
  users                Manage users
  claude               Run Claude Code through your LiteLLM proxy
  codex                Run Codex through your LiteLLM proxy
  opencode             Run OpenCode through your LiteLLM proxy
  version              Show version information
  help                 Show this help message
  quit                 Exit the interactive session

--- driver stderr ---
PKCE DRIVER: GET http://localhost:9714/authorize (consent page)
PKCE DRIVER: POST /authorize/complete decision=approve -> HTTP 200

########## P2. WHERE EACH HALF OF THE PKCE CREDENTIAL LANDED ##########
$ cat $HOME/.litellm/token.json
  base_url:             'http://localhost:9714'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249440.80051
  expires_at:           1787335840.800489
  refresh_token:        'llm_srefresh...REDACTED...'
  client_id:            'llm_dcrc_w7-...REDACTED...'
  token_endpoint:       'http://localhost:9714/token'
  revocation_endpoint:  'http://localhost:9714/revoke'
  resource:             'http://localhost:9714'
--- keychain blob (read in-process) ---
keychain blob key:       jjUpmtXRZmH_...REDACTED...
blob base_url:           http://localhost:9714
blob timestamp:          1787249440.80051
blob has refresh_token:  False
preflight left over:     NO

########## P3. THE SCAVENGER AGAINST A PKCE LOGIN ##########
everything on disk that could be a credential: {"base_url": "http://localhost:9714", "user_id": "qa5855_run8_user", "user_email": "unknown", "user_role": "cli", "auth_header_name": "Authorization", "refresh_token": "llm_srefresh...REDACTED...", "client_id": "llm_dcrc_w7-...REDACTED...", "token_endpoint": "http://localhost:9714/token", "revocation_endpoint": "http://localhost:9714/revoke", "resource": "http://localhost:9714"}
secret fields found: NONE
bearer token it can build: ''
HTTP 401 Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.

########## P4. A REAL PROVIDER CALL ON THE PKCE CREDENTIAL ##########
"content": "QA5855 PKCE 5f7c0e1e49 authenticated this call"

########## P5. FORCE THE SILENT RENEWAL, AND SEE WHERE THE NEW KEY GOES ##########
--- key before renewal (keychain) ---
keychain blob key:       jjUpmtXRZmH_...REDACTED...
--- expiring the stored credential by hand (expires_at -> the past) ---
  expires_at set to 60 seconds ago; everything else untouched
--- next ordinary command, which must renew silently ---
"content": "QA5855 RENEWED 5f7c0e1e49 authenticated this call"
--- where the renewed key is now ---
keychain blob key:       N8E7sjU7YHJa...REDACTED...
blob base_url:           http://localhost:9714
blob timestamp:          1787249447.6782799
blob has refresh_token:  False
preflight left over:     NO
$ cat $HOME/.litellm/token.json
  base_url:             'http://localhost:9714'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249447.6782799
  expires_at:           1787335847.678271
  refresh_token:        'llm_srefresh...REDACTED...'
  client_id:            'llm_dcrc_w7-...REDACTED...'
  token_endpoint:       'http://localhost:9714/token'
  revocation_endpoint:  'http://localhost:9714/revoke'
  resource:             'http://localhost:9714'
  renewed key is in the keychain:       YES
  renewed key is in token.json:         NO
  refresh token is in token.json:       YES

########## P6. LOGOUT REVOKES THE REFRESH TOKEN ON THE PROXY ##########
  (stashed the refresh token out of band, to replay it after logout)
$ lite logout
Logged out successfully. Authentication token cleared.
--- both stores after logout ---
total 0
drwx------@ 2 mateo  wheel   64 Aug 20 11:10 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:10 ..
security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- can the revoked refresh token still buy a new key? ---
  POST http://localhost:9714/token -> 400
   {"error":"invalid_grant","error_description":"the refresh token was already used"}

What this PR does not fix: the refresh token is still in the file

A --pkce login writes refresh_token into ~/.litellm/token.json in the clear, and this PR does not move it. The key is in the keychain, so the scavenger above cannot read a bearer token out of the file, but it can POST that refresh token to the proxy's /token endpoint and be handed a working one. The file alone still buys access.

The gap, demonstrated
signed in with --pkce at 5f7c0e1e49

$ python scavenger_pkce.py
secret-looking fields on disk: ['refresh_token']
bearer token it can read straight out of the file: ''
POST http://localhost:9714/token with the file's refresh_token -> 200
access token it minted: Cop3d3Lv1yGq...REDACTED...
chat completion with the minted key -> 200 -- THE FILE ALONE STILL BUYS A WORKING CREDENTIAL

--- cleaning up ---
Logged out successfully. Authentication token cleared.

This is the remaining half of LIT-5855 and it is tracked as a follow-up. It is scoped small now that the merge landed native refresh and revocation: the refresh token needs to travel in the keychain blob next to the key, the same way the key already does.

The edge cases, re-run at the tip

The merge rewrote auth.py's save, load, and logout paths into vault-backed adapters, so the failure modes were driven again against the merged code rather than carried over.

The six edge-case legs
########## WHICH TREE ##########
$ git -C <after worktree> rev-parse HEAD
5f7c0e1e49c70b030aad11d38dcf82b1f0149a0c

########## E1. THE KILL SWITCH: LITELLM_CLI_DISABLE_KEYRING=1 ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- what login said ---
Login successful!
JWT Token: 3mO-PpEgNMAfqLOLX0Fv...
Keychain storage is off (LITELLM_CLI_DISABLE_KEYRING). Credential stored in $QA/home_E1/.litellm/token.json (owner-only).
You can now use the CLI without specifying --api-key
$ stat -f '%Sp %N' $HOME/.litellm/token.json
-rw------- $HOME/.litellm/token.json
$ cat $HOME/.litellm/token.json
  base_url:             'http://localhost:9714'
  key:                  '3mO-PpEgNMAf...REDACTED...'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249828.39043
--- the machine keychain was never touched ---
slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
$ lite models list (first line)
                Available Models                
┏━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ ID    ┃ Object ┃ Created          ┃ Owned By ┃
$ lite logout
Logged out locally, but your OS keychain could not be checked, so a credential stored there by an earlier login may still be usable.
Unset LITELLM_CLI_DISABLE_KEYRING and run 'lite logout' again to clear it.
$ ls $HOME/.litellm
token.json

########## E2. A BACKEND THAT ACCEPTS WRITES AND KEEPS NOTHING ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- what login said ---
Login successful!
JWT Token: eVGp1zjKbzBwWZPD3Gs1...
Your keyring backend keeps nothing it is given, so the credential was stored in $QA/home_E2/.litellm/token.json (owner-only) instead. For OS keychain storage, run: keyring --enable (or unset PYTHON_KEYRING_BACKEND)
You can now use the CLI without specifying --api-key
$ cat $HOME/.litellm/token.json
  base_url:             'http://localhost:9714'
  key:                  'eVGp1zjKbzBw...REDACTED...'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249833.739985
$ lite whoami
Authenticated
User Email: unknown
User ID: qa5855_run8_user
User Role: cli
Token age: 0.0 hours
$ lite models list (first line)
                Available Models                
┏━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ ID    ┃ Object ┃ Created          ┃ Owned By ┃

########## E3. A token.json WRITTEN BY AN OLDER lite MIGRATES ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- pulling the freshly minted key back out, and rebuilding the pre-PR file by hand ---
  token.json now carries the key in the clear, the keychain slot is empty
slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
$ lite models list  <- one ordinary command, nothing about migration
                Available Models                
┏━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ ID    ┃ Object ┃ Created          ┃ Owned By ┃
--- where the key is now ---
$ cat $HOME/.litellm/token.json
  base_url:             'http://localhost:9714'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249840.173078
keychain blob key:       slZWY3dEksMh...REDACTED...
blob base_url:           http://localhost:9714
blob timestamp:          1787249840.173078
blob has refresh_token:  False
preflight left over:     NO

########## E4. LOGOUT WHEN THE KEYCHAIN WILL NOT RELEASE THE ENTRY ##########
--- what login said ---
Login successful!
JWT Token: kbiRPKy1KBXtzSVGnx8_...
Credential stored in your OS keychain.
You can now use the CLI without specifying --api-key
$ lite logout
Logged out locally, but your credential is still in the OS keychain and could not be removed.
Unlock your keychain and run 'lite logout' again to clear it.
--- the entry the keychain would not give up is still there ---
  keys still held: ['credential-preflight', 'credential']
$ ls -A $HOME/.litellm  (the file half was still cleared)

########## E5. whoami WHEN THE KEYCHAIN HOLDS THE CREDENTIAL BUT WILL NOT READ IT ##########
  signed in; the credential is in the stub keychain, token.json has no key:
  base_url:             'http://localhost:9714'
  user_id:              'qa5855_run8_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787249850.129784
  now the same keychain refuses every read
$ lite whoami
Signed in, but the credential cannot be read
User Email: unknown
User ID: qa5855_run8_user
User Role: cli
Token age: 0.0 hours
Your credential is in your OS keychain, which could not be read. Unlock it, or run 'lite login' to start over.
$ lite auth print-token
Your credential is in your OS keychain, which could not be read. Unlock it, or run 'lite login' to start over.

########## E6. THE SLOT IS ONE PER OS USER, NOT ONE PER $HOME ##########
slot after clean: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
  signed in under $HOME=.../home_E6a
keychain blob key:       LX4_4ZY4YRjO...REDACTED...
  now a second $HOME with no token.json of its own
$ ls -A $HOME/.litellm
ls: $QA/home_E6b/.litellm: No such file or directory
  (no ~/.litellm at all)
$ lite whoami
Not authenticated. Run 'lite login' to authenticate.
  and a logout from this second $HOME clears the first one's credential
$ lite logout
Logged out successfully. Authentication token cleared.
slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
$ lite whoami   (back in the first $HOME)
Not authenticated. Run 'lite login' to authenticate.

EDGES DONE

Reading those in order:

  • E1, the kill switch. LITELLM_CLI_DISABLE_KEYRING=1 keeps the credential in the owner-only file and never touches the machine keychain, and login says so by name. Logout blanks the secret out of the file but leaves the metadata behind and warns, because with the keychain switched off it cannot confirm that an earlier login left nothing there
  • E2, a backend that accepts writes and keeps nothing. Login reads the value back, finds it gone, falls back to the file, and names the fix. whoami and a real request both work
  • E3, migration. A token.json carrying a key in the clear, the shape an older lite wrote, is picked up by the next ordinary command: the key moves to the keychain and comes out of the file, with nothing printed about it
  • E4, a keychain that will not release the entry. Logout says the credential is still there rather than reporting success, and the file half is cleared anyway
  • E5, a keychain that holds the credential and refuses every read. whoami opens with Signed in, but the credential cannot be read instead of Authenticated, and print-token says the same rather than printing nothing
  • E6, the machine-wide slot. A second $HOME does not inherit the first one's session, because the metadata it needs is in the first one's token.json. What it does share is the slot itself, so a lite logout from the second $HOME clears the credential the first one was using, and the first one then reads as not authenticated

What surprised me

  • security find-generic-password -w hangs on an approval dialog. PR leaves alone.
  • The 5-second keychain pre-flight is paid per process. PR causes it.
  • Login still echoes the credential's first 20 characters. PR leaves alone.
  • A directory at token.json breaks auth despite a good keychain. PR leaves alone.
  • A logout that cannot remove the file still exits 0. PR leaves alone.
  • One keychain entry per Mac user, every $HOME shares. PR causes it.
  • A hand-stamped future login makes whoami print negative age. PR leaves alone.
  • A full keychain volume reads fine and refuses writes. PR handles it.
  • The lite REPL never re-runs its group callback per command. PR leaves alone.
  • A --pkce refresh token sits in the token file in the clear. PR leaves alone.

Type

🆕 New Feature

Caveats (if any)

  • Revoking a live CLI session ships separately in feat(auth): list and revoke lite login CLI sessions from the Admin UI #36846
  • keyring installs with the cli extra
  • On Linux that extra now also pulls cryptography
  • Headless boxes keep today's file-based exposure
  • One keychain entry per OS user, not per $HOME
  • A sign-in inherits a stored stamp that sits in the future
  • A keychain that takes reads but refuses writes costs 5s a command
  • The SDK getter needs the cli extra to reach a keychain credential
  • A backend that discards writes reads back as not authenticated
  • A --pkce refresh token stays in token.json in the clear

The keychain entry is one per operating-system user, so two $HOMEs on the same account pointed at the same gateway now share one slot where they used to keep a token file each. A second $HOME does not silently inherit the first one's session, because the metadata naming the gateway and the user is still in the first one's token.json, so it reads as not authenticated. What they do share is the slot: the last login wins it, and a lite logout from either $HOME clears the credential the other was using. Keying the entry per $HOME would restore the split, and it would also mint entries that a logout from any other $HOME can never find or clear, which is the stranded-credential failure the logout logic exists to prevent. $HOME was never a trust boundary inside one OS account either, since that user could already read every other $HOME's token file, so the split was not buying isolation. It stays one entry per user

A sign-in is stamped past the latest stamp either store already holds, which is what keeps the ordering of the two independent of the clock, and it costs lite login one keychain read before the write it was going to make anyway. A store left carrying a stamp in the future therefore hands that stamp to the next login as well, where before this PR a fresh login would have reset it to the current time. The one thing that reads it is the local expiry shortcut behind lite auth print-token and lite up, so the cost is that they stop failing fast and let the gateway reject the call instead; the gateway is what enforces expiry either way. Giving the arbitration a counter of its own would separate the two, and it would add a second stored field and a migration for it to close a gap that costs a round trip, so the stamp stays shared

A machine whose keychain answers reads but refuses writes pays the five-second write pre-flight on every command that still finds a secret in token.json, because the migration that would take the secret out of the file is the thing that cannot finish. The file keeps its copy, so the next command tries again and pays it again. Containers, CI images, sudo -H, and service accounts are where this lands, and lite auth print-token is what Claude Code calls as its apiKeyHelper, so the cost is per request rather than per session. Measured on one such box, print-token went from 1.30s and 1.13s before this PR to 6.15s and 6.27s after. Setting LITELLM_CLI_DISABLE_KEYRING=1 skips the keychain and returns those boxes to the file-only path they were already on. Remembering the refusal across processes would fix it properly, and it needs somewhere to write that verdict down, which is its own change

litellm.get_litellm_gateway_api_key() reads the keychain now, and keyring ships only with the cli extra, so an install without that extra cannot see a credential lite login put in the keychain and returns None without saying why. An install that never had keyring is unaffected, because its own login fell back to the token file and the getter still reads it there. The gap is the mixed case, one environment signing in with the extra and another reading without it, and installing litellm[cli] on the reading side closes it

A lite login --pkce credential comes in two pieces, and this PR only moves one of them. The key goes to the keychain, and every silent renewal puts the new key there too, but the refresh token that mints those keys stays in ~/.litellm/token.json in the clear. A second process running as the user cannot read a bearer token out of that file any more, and it does not need one: it can post the refresh token to the proxy's /token endpoint and be handed a key that works, which is the leg above. So token.json is still worth protecting on a --pkce login, and the exposure this PR closes is narrower there than it is for lite login. Moving the refresh token into the keychain blob alongside the key is the rest of LIT-5855 and is tracked as a follow-up; it was not folded in here because the PKCE flow arrived from the base branch mid-review and widening the scope during a merge resolution is how merges go wrong

A keyring backend that accepts writes and keeps nothing reads back as SecretMissing, which is the same answer a machine that never signed in gives, so lite whoami says not authenticated rather than naming the backend. The write path catches this by reading the value back and says so plainly; the read path has nothing to compare against, since telling the two apart needs a write. lite login names it at the moment it happens, which is the point where the user can act on it

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
  • 5f7c0e1e49c70b030aad11d38dcf82b1f0149a0c passes /live-pr-risk, with its findings recorded under Caveats

lite login used to write the minted cli-session key in cleartext to
~/.litellm/token.json. The secret material (key plus any JWT) now goes
to the OS keychain through the optional keyring package, with the 0600
file kept for non-secret metadata and as the fallback on headless boxes.
Legacy plaintext files keep authenticating and are migrated into the
keychain, then scrubbed, on first read. A secret still on disk always
outranks the keychain entry, so a failed keychain write can never
resurrect a stale key. LITELLM_PROXY_API_KEY and --api-key precedence
is unchanged, lite logout clears both stores and warns when the
keychain will not release the entry, and ~/.litellm is created 0700
(tightened from 0755 where an older CLI left it broader).
LITELLM_CLI_DISABLE_KEYRING=1 forces the file fallback.
Drop the inline notes on keychain erasure and disk-vs-vault precedence in favour
of docstrings on the two functions that own those rules, and remove a stale
section header and a field note that the code already says plainly.
@mateo-berri
mateo-berri requested a review from a team August 20, 2026 02:12
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves Lite CLI credentials into the operating-system keychain while retaining private file fallback and legacy-token migration.

  • Adds bounded, optional keyring access with explicit unavailable, disabled, and failed-storage outcomes.
  • Adds cross-store credential arbitration, secure metadata persistence, migration, and logout cleanup.
  • Updates CLI commands, dependency metadata, documentation, and focused authentication tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/cli_keyring.py Adds the optional OS-keychain abstraction, bounded write preflight, read-back verification, and explicit result types.
litellm/litellm_core_utils/cli_token_utils.py Splits secret and metadata storage, migrates legacy credentials, orders competing store entries, and tracks incomplete logout cleanup.
litellm/litellm_core_utils/private_json.py Adds owner-only atomic JSON staging, replacement, and in-place overwrite helpers.
litellm/proxy/client/cli/commands/auth.py Integrates the new storage outcomes into login, logout, identity, and token-printing behavior.
pyproject.toml Adds keyring support to the CLI dependency extra.
tests/test_litellm/litellm_core_utils/test_cli_token_utils.py Exercises migration, competing-store ordering, clock movement, rollback, fallback, and repeated-logout behavior.
tests/test_litellm/proxy/client/cli/test_auth_commands.py Verifies user-facing authentication behavior for successful and degraded credential-storage outcomes.

Reviews (22): Last reviewed commit: "test(cli): pin the shared stamp's effect..." | Re-trigger Greptile

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated
Comment thread litellm/litellm_core_utils/cli_keyring.py
lite ships with every install of litellm, but the keyring package it needs
for keychain storage only ships with the cli extra. Such a user on a Mac was
told 'No OS keychain available' about a machine that plainly has one, with
nothing pointing at the missing package.

The vault now reports which of the three unusable states it is in, so login
can point at the install, name the kill switch, or report a genuinely absent
keychain.
…itellm_cli_refresh_tokens

# Conflicts:
#	basedpyright-code-budget.json
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.96970% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/client/cli/commands/auth.py 90.67% 11 Missing ⚠️
litellm/litellm_core_utils/cli_keyring.py 98.29% 2 Missing ⚠️
litellm/litellm_core_utils/cli_token_utils.py 98.97% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Logout reports success without keychain
    • Removed the early returns in KeyringVault.erase so a missing or disabled keyring package now maps through read() to False, causing lite logout to warn instead of silently claiming success when a credential from another environment may still live in the OS keychain.

Create PR

Or push these changes by commenting:

@cursor push 4225a53d9e
Preview (4225a53d9e)
diff --git a/litellm/litellm_core_utils/cli_keyring.py b/litellm/litellm_core_utils/cli_keyring.py
--- a/litellm/litellm_core_utils/cli_keyring.py
+++ b/litellm/litellm_core_utils/cli_keyring.py
@@ -120,13 +120,10 @@
     def erase(self) -> bool:
         """Whether the keychain is guaranteed to hold no credential afterwards.
 
-        An uninstalled `keyring` package can never have stored one. A kill switch set after
-        a credential was stored leaves that entry out of reach, so erasure cannot be promised.
+        An uninstalled or disabled `keyring` package leaves any credential a different
+        environment (e.g. an install with the `cli` extra) stored under this service out
+        of reach, so erasure cannot be promised. A locked keychain is the same story.
         """
-        if _import_keyring() is None:
-            return True
-        if _keyring_disabled():
-            return False
         match self.read():
             case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable():
                 return False

diff --git a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
--- a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
+++ b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
@@ -402,14 +402,16 @@
 
     def test_an_uninstalled_keyring_library_degrades_to_the_file(self, monkeypatch):
         """keyring is an optional extra, so the SDK must survive its absence rather than raise on
-        the hot path."""
+        the hot path. Erase still fails: a credential stored from another environment (e.g. an
+        install with the `cli` extra) may be in the keychain, and without keyring `lite logout`
+        cannot verify it is gone, so it must warn instead."""
         monkeypatch.delenv(DISABLE_KEYRING_ENV_VAR, raising=False)
         monkeypatch.setitem(sys.modules, "keyring", None)
         vault = KeyringVault()
 
         assert vault.read() == KeyringNotInstalled()
         assert vault.write("blob-1") == KeyringNotInstalled()
-        assert vault.erase() is True
+        assert vault.erase() is False
 
     def test_a_locked_keychain_is_reported_not_raised(self, install_fake_keyring):
         install_fake_keyring(_FakeKeyringModule(get_error=RuntimeError("keyring is locked")))

You can send follow-ups to the cloud agent here.

Comment thread litellm/litellm_core_utils/cli_keyring.py Outdated
Migration moved the secret into the keychain and then suppressed any OSError from
rewriting token.json, so a file that could not be rewritten kept the credential in
cleartext while every command reported success. That file is now removed instead:
signing in again costs one command, a stranded live credential costs the credential

`lite logout` also reported a clean logout whenever the keyring package was missing,
on the reasoning that an install without it could never have stored anything. The
entry belongs to the OS, so a keychain-backed login survives a logout run from a venv
without the cli extra. erase() now reports which keychain state applies, and logout
warns with the advice that fixes each one, staying quiet for file-backed logins whose
token file still carries its own secret

Also pins the migration path's tightening of a world-readable legacy token.json, and
moves the logout tests off patch() onto the injected vault
… be removed

Removing the file when it could not be rewritten covered a full disk, but not a
~/.litellm that permits neither the rewrite nor the delete, which is what a
`sudo lite login` leaves behind. There the secret was copied into the keychain and
kept in cleartext on disk, so migration widened exposure instead of narrowing it

Migration now only keeps the vault copy if the file's copy is gone. When it is not,
the write is rolled back and the user is left exactly as they were, logged in with
one copy of the credential
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated
@veria-ai

veria-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request updates the CLI’s lite login flow to store credentials in the operating system keychain, including changes to CLI token persistence and logout handling.

One security issue has already been addressed, but logout can still leave an active refresh token in token.json when revocation fails and the keychain is unavailable. A process able to read that file could redeem the token for new access keys, so logout should always scrub it even if other metadata must remain.

Open issues (1)

Fixed/addressed: 1 · PR risk: 6/10

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Logout success with leftover keychain
    • Restricted the _secret_lives_in_keychain heuristic to KeyringNotInstalled, so KeyringDisabled and KeyringUnreachable now bubble up to lite logout as SecretStranded-style warnings instead of being masked as SecretErased when the file still holds its own secret.

Create PR

Or push these changes by commenting:

@cursor push 0b10c8c111
Preview (0b10c8c111)
diff --git a/litellm/litellm_core_utils/cli_token_utils.py b/litellm/litellm_core_utils/cli_token_utils.py
--- a/litellm/litellm_core_utils/cli_token_utils.py
+++ b/litellm/litellm_core_utils/cli_token_utils.py
@@ -105,13 +105,20 @@ def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> SecretErase:
 
 
 def _nothing_left_behind(outcome: SecretErase) -> bool:
-    """Whether the keychain can be trusted to hold no credential of ours once the file is gone"""
+    """Whether the keychain can be trusted to hold no credential of ours once the file is gone.
+
+    Only `KeyringNotInstalled` can lean on the token file: a machine with no keyring package cannot
+    have put a credential in one from this install. `KeyringDisabled` (kill switch flipped after an
+    earlier login) and `KeyringUnreachable` (backend locked or broken) both leave the door open to
+    a live entry the current process cannot see, so a file that has since fallen back to holding its
+    own secret is not proof the keychain is clean.
+    """
     match outcome:
         case SecretErased():
             return True
-        case SecretStranded():
+        case SecretStranded() | KeyringDisabled() | KeyringUnreachable():
             return False
-        case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable():
+        case KeyringNotInstalled():
             return not _secret_lives_in_keychain()
 
 

@@ -105,13 +105,20 @@ def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> SecretErase:
 
 
 def _nothing_left_behind(outcome: SecretErase) -> bool:
-    """Whether the keychain can be trusted to hold no credential of ours once the file is gone"""
+    """Whether the keychain can be trusted to hold no credential of ours once the file is gone.
+
+    Only `KeyringNotInstalled` can lean on the token file: a machine with no keyring package cannot
+    have put a credential in one from this install. `KeyringDisabled` (kill switch flipped after an
+    earlier login) and `KeyringUnreachable` (backend locked or broken) both leave the door open to
+    a live entry the current process cannot see, so a file that has since fallen back to holding its
+    own secret is not proof the keychain is clean.
+    """
     match outcome:
         case SecretErased():
             return True
-        case SecretStranded():
+        case SecretStranded() | KeyringDisabled() | KeyringUnreachable():
             return False
-        case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable():
+        case KeyringNotInstalled():
             return not _secret_lives_in_keychain()
 
 

diff --git a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
--- a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
+++ b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
@@ -400,6 +400,19 @@ def test_a_file_backed_login_logs_out_quietly_without_keyring(self, isolated_hom
     def test_is_safe_when_nothing_was_ever_stored(self, isolated_home, secret_vault_factory):
         assert clear_cli_token(vault=secret_vault_factory()) == SecretErased()
 
+    @pytest.mark.parametrize("failure", [KeyringDisabled(), KeyringUnreachable()])
+    def test_a_file_that_fell_back_from_a_now_unreachable_keychain_still_warns(
+        self, isolated_home, secret_vault_factory, failure
+    ):
+        """An earlier keychain-backed login could have left an entry a subsequent fallback-to-file
+        login never cleared. When the current process cannot reach the keychain to check, a file
+        that has since regained its own secret is not evidence the keychain is clean."""
+        _write_legacy_file(isolated_home)
+        vault = secret_vault_factory(available=False, failure=failure)
+
+        assert clear_cli_token(vault=vault) == failure
+        assert not _token_file(isolated_home).exists()
+
 
 class TestIsCliTokenFresh:
     def test_a_just_issued_token_is_fresh(self):

@@ -400,6 +400,19 @@ def test_a_file_backed_login_logs_out_quietly_without_keyring(self, isolated_hom
     def test_is_safe_when_nothing_was_ever_stored(self, isolated_home, secret_vault_factory):
         assert clear_cli_token(vault=secret_vault_factory()) == SecretErased()
 
+    @pytest.mark.parametrize("failure", [KeyringDisabled(), KeyringUnreachable()])
+    def test_a_file_that_fell_back_from_a_now_unreachable_keychain_still_warns(
+        self, isolated_home, secret_vault_factory, failure
+    ):
+        """An earlier keychain-backed login could have left an entry a subsequent fallback-to-file
+        login never cleared. When the current process cannot reach the keychain to check, a file
+        that has since regained its own secret is not evidence the keychain is clean."""
+        _write_legacy_file(isolated_home)
+        vault = secret_vault_factory(available=False, failure=failure)
+
+        assert clear_cli_token(vault=vault) == failure
+        assert not _token_file(isolated_home).exists()
+
 
 class TestIsCliTokenFresh:
     def test_a_just_issued_token_is_fresh(self):

You can send follow-ups to the cloud agent here.

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated
…t done

A keyring backend can accept a write and keep nothing. That is exactly what
`keyring --disable` and PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring
select, and it raises nothing to distinguish itself, so `lite login` was
handing the credential to a black hole, scrubbing its own copy from
token.json, and printing a success message over a login that no longer
worked. Reading the value back is the only way to tell that backend apart
from a keychain that really stored the secret.

The same rule closes the rest of the gaps. A credential the token file will
not record is taken back out of the keychain instead of being left live on a
machine with no record of it, and is reported rather than raised. The
migration stages its scrubbed file before the keychain is handed anything,
so a directory that will not accept the rewrite stops the move rather than
leaving the secret in two places. Logout no longer reads a key in the file
as proof that the keychain is clear, which was never sound across two
separate runs, and only draws that conclusion when the `keyring` package is
missing outright, where nothing could have reached a keychain at all.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Failed save wipes a findable login
    • save_cli_token now consults the surviving token.json's base_url and, when it still pairs with the freshly stored vault entry, keeps the secret instead of erasing it and reporting the credential lost.

Create PR

Or push these changes by commenting:

@cursor push 1e60366e2c
Preview (1e60366e2c)
diff --git a/litellm/litellm_core_utils/cli_token_utils.py b/litellm/litellm_core_utils/cli_token_utils.py
--- a/litellm/litellm_core_utils/cli_token_utils.py
+++ b/litellm/litellm_core_utils/cli_token_utils.py
@@ -108,7 +108,10 @@
 
     The token file is what makes a keychain-backed credential findable again, so a file that will
     not be written takes the keychain copy down with it rather than leaving a live credential
-    stored under a machine that has no record of it.
+    stored under a machine that has no record of it. A same-server re-login is the exception: an
+    earlier successful save's file survives an atomic rewrite that never lands, and still pairs
+    with the vault slot the new secret just replaced, so the login is findable and the vault
+    entry stays.
     """
     outcome: Final = (
         SecretStored()
@@ -119,11 +122,26 @@
         _write_token_file(_without_secret(record) if isinstance(outcome, SecretStored) else record)
     except OSError as error:
         if record.key is not None and isinstance(outcome, SecretStored):
+            if _existing_file_pairs_with(record.base_url):
+                return outcome
             vault.erase()
         return CredentialNotSaved(str(error))
     return outcome
 
 
+def _existing_file_pairs_with(base_url: str) -> bool:
+    """Whether a surviving token.json still points at this same-server login.
+
+    `write_private_json` is atomic: it fails on the staged temp file, so an aborted metadata
+    rewrite leaves the previous file untouched. `_apply_vault_secret` pairs that file with the
+    vault entry we just refreshed whenever the base_url still matches, so the credential is
+    findable on the next call even though the rewrite that would have refreshed the metadata
+    did not land.
+    """
+    existing: Final = _read_token_file()
+    return existing is not None and existing.base_url == base_url
+
+
 def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> SecretErase:
     """Remove the credential from both stores. Reports whether the keychain is now free of it"""
     outcome: Final = vault.erase()

diff --git a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
--- a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
+++ b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py
@@ -346,6 +346,47 @@
 
         assert vault.blob is None
 
+    def test_a_same_server_relogin_keeps_the_new_secret_when_the_prior_file_still_pairs(
+        self, isolated_home, secret_vault_factory, monkeypatch
+    ):
+        """`write_private_json` fails on the staged temp file, so a prior successful save's
+        token.json survives an aborted rewrite. Its base_url still pairs with the vault slot the
+        new secret just replaced, so the login is findable and the fresh secret must not be
+        erased on top of the one it just overwrote."""
+        _write_metadata_only_file(isolated_home)
+        vault = secret_vault_factory(blob=_blob(key="sk-old"))
+
+        def _explode(*args, **kwargs):
+            raise OSError("read-only file system")
+
+        monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode)
+
+        outcome = save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=vault)
+
+        assert outcome == SecretStored()
+        assert json.loads(vault.blob)["key"] == "sk-new"
+        assert vault.erases == 0
+        assert load_cli_token(vault=vault).key == "sk-new"
+
+    def test_a_different_server_relogin_still_erases_when_the_prior_file_cannot_pair(
+        self, isolated_home, secret_vault_factory, monkeypatch
+    ):
+        """A prior file pointing at another server does not make the new secret findable: the
+        vault entry would be stranded under metadata that names the wrong base_url, so it has to
+        come back out."""
+        _write_metadata_only_file(isolated_home)
+        vault = secret_vault_factory(blob=_blob(key="sk-old"))
+
+        def _explode(*args, **kwargs):
+            raise OSError("read-only file system")
+
+        monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode)
+
+        outcome = save_cli_token(CliTokenRecord(base_url=OTHER_SERVER, key="sk-new"), vault=vault)
+
+        assert isinstance(outcome, CredentialNotSaved)
+        assert vault.blob is None
+
     def test_a_failed_write_leaves_the_previous_credential_intact(self, isolated_home, secret_vault_factory, monkeypatch):
         path = _write_legacy_file(isolated_home)
         before = path.read_text()

You can send follow-ups to the cloud agent here.

Comment thread litellm/litellm_core_utils/cli_token_utils.py
@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_cli_refresh_tokens (5f7c0e1) with litellm_internal_staging (b091158)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (5f7c0e1) during the generation of this report, so b091158 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

`make lint` hands every path in the diff against the base branch to `ruff
format --check`, including the ones the branch deleted, so any branch that
moves or removes a file under `litellm/` fails the gate with "No such file or
directory" instead of a formatting complaint.

test-linting.yml already filters those out with `--diff-filter=ACMR`, so the
Makefile was the half that drifted. Match it.
…t answer

Three ways the credential commands could mislead or hang.

`lite logout` on a machine that never logged in warned that a credential may
be stranded in a keychain it could not check, and told the user to install
keyring to go clear it. There was nothing there. A missing token file is now
read as the evidence it is, because logout keeps a secret-free file behind
whenever the keychain is left unconfirmed, so a later run can tell a machine
with a credential it cannot reach apart from one that never had a login. That
holds on the LITELLM_CLI_DISABLE_KEYRING path too.

`KeyringDiscardsWrites` was handled on the read and erase paths, which cannot
produce it: the null backend returns None from `get_password` rather than
raising, so only a write ever detects it. It now lives on `SecretWrite` alone
and the unreachable arms are gone.

`keyring.set_password` blocks forever under a HOME with no usable login
keychain, which is what containers, CI images, `sudo -H`, and service accounts
run with, and reads answer normally there so nothing cheaper tells them apart.
`lite login` never touched a keychain before this, so a sign-in that simply
never returns would be a new way for it to fail. Writes are pre-flighted with
a throwaway value on a bounded wait, and a keychain that stays silent falls
back to the token file. The real credential is never the thing handed to a
call that might land long after we stopped waiting.

Saving also stages the token file before the keychain is given anything, since
the file is the half a read-only or full directory refuses. A save that cannot
land now leaves both stores as it found them, which matters most when the
login it failed to replace still works.
…name

Staging the token file can succeed and the replacement still fail afterwards,
and that is the one save path where the keychain has already taken the new
secret. It was reported as a save that kept nothing, which sends the user
looking for a credential that is sitting in their keychain, and it claimed the
previous login was untouched when the one keychain slot had just been written
over.

Give that path its own outcome and its own notice. The new secret stays where
it is: the entry it replaced went the moment it landed, so no rollback brings
that back, and removing the new one too would turn a login this machine may
still be able to use into no login at all.

The remaining `CredentialNotSaved` paths all leave both stores untouched, so
the reassurance they carry is now true wherever it is printed.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/litellm_core_utils/cli_token_utils.py Outdated
A logout that could not reach the keychain deleted the token file whenever it
still held its own secret, and the next logout read that missing file as proof
the keychain was clean. It answered the warning the first run had just issued
with "Logged out successfully" while the entry an earlier login left behind was
still live. The file is the only record that something may still be in there,
which is what `_nothing_left_behind` already says it relies on, so keep it and
take only the secret out.

A keychain that did answer is a different case. `SecretStranded` means the entry
is confirmed there and would not delete, and that needs no note in the file,
while keeping one lets every later command read the credential straight back out
of the keychain, which makes "Logged out locally" untrue. That one drops the
file, as it did before.

The secret still goes first either way: a copy that cannot be replaced with a
secret-free one is removed rather than kept.
The two stores hold different credentials on that path, so the rollback a
migration does would hand the superseded one back out. The login that could not
replace the file already named the state, and logout reports it too.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/litellm_core_utils/cli_token_utils.py
Mateo Edgeton added 2 commits August 20, 2026 04:53
The stamp in the keychain entry is what decides that secret against one still
sitting in the token file, and it came straight off the wall clock. A clock
that stepped backwards between two logins therefore handed the win to the
older of them: a login the keychain took but the token file could not be
pointed at was resolved back to the credential it replaced, and the fresh one
was erased from the keychain on the way past.

save_cli_token now reads the stamp already on disk and pins the new sign-in
just above it, so the ordering never depends on the clock having moved
forwards. On a clock that did, this changes nothing.
The comment above the extra named cryptography as one of the heavy imports a
thin install leaves out. That stopped being true when keyring joined the
extra: on Linux it reaches the Secret Service through secretstorage, which
depends on cryptography.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

A login the keychain took but the token file could not record leaves the
keychain naming a later sign-in than the file does. Reading only the file
then stamps the next login below that keychain entry, and a clock that
went back far enough puts the superseded credential back in use.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/litellm_core_utils/cli_token_utils.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

FakeSecretVault could only stand in for a discarding backend by passing
KeyringDiscardsWrites as its `failure`, which also made read() and erase()
hand it back. Neither SecretRead nor SecretErase admits that outcome and the
real KeyringVault never produces it there, so the login path's match was
falling through on a value it can never see. Give the double a `discards`
flag that reports it from write() alone, which is what the null backend does.

Also widen lint-format-check-changed's pathspec. Git wildmatch runs without
FNM_PATHNAME here, so 'litellm/**/*.py' still requires an intermediate
directory and silently skipped all 21 top-level modules, litellm/__init__.py
and litellm/main.py among them. All 21 already pass ruff format.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Format check skips nested Python files
    • Restored the recursive litellm/**/*.py pathspec in lint-format-check-changed so it matches CI's test-linting.yml and covers nested modules.

Create PR

Or push these changes by commenting:

@cursor push 4769a37798
Preview (4769a37798)
diff --git a/Makefile b/Makefile
--- a/Makefile
+++ b/Makefile
@@ -146,7 +146,7 @@
 # only the litellm Python files changed vs the base are checked, so a pre-existing
 # format issue elsewhere doesn't block an unrelated commit.
 lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
-	@files=$$(git diff --name-only --diff-filter=ACMR origin/litellm_internal_staging...HEAD -- 'litellm/*.py' | grep -v '^litellm/enterprise/' || true); \
+	@files=$$(git diff --name-only --diff-filter=ACMR origin/litellm_internal_staging...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' || true); \
 	if [ -z "$$files" ]; then \
 		echo "No changed litellm Python files to format-check."; \
 	else \

You can send follow-ups to the cloud agent here.

Comment thread Makefile
The stamp both orders the two stores and drives is_cli_token_fresh, and
nothing tied the two together, so a login that inherits a stamp from the
future could stop being a deliberate trade without anything failing.

Also corrects the lint-format-check-changed comment: git pathspecs match
recursively, so the target checks a superset of the CI step rather than
an identical set.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

You withdrew this at line 422, then scored 5/5 on code identical to this tip. A failed rollback retries on the next read

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

That matches what the code does: after a failed rollback, _migrate_file_secret returns record with no error propagation, so _resolve_secret sees the same state on the next call and retries the whole migration path — stage, write, commit — without any extra machinery. The docstring says this explicitly too ("a rollback the keychain also refuses leaves the secret in both stores until the next read, which retries this same migration"). You're right, and I shouldn't have flagged it.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1fe06a1. Configure here.

@mateo-berri
mateo-berri enabled auto-merge August 20, 2026 13:39
Base landed the native CLI OAuth + PKCE login, which added its own token
storage and a silent refresh that wrote the key straight to token.json.
This branch had already moved that secret into the OS keychain, so the two
had to be joined rather than picked between.

auth.py now keeps one pair of record helpers, load_token and save_token,
that read and write through the vault and hand the PKCE layer the plain
mapping it works with. fresh_api_key and revoke_stored_credential get
vault-bound save and reload callables, so a renewed key is stored in the
keychain like any other and a sibling process's rotation is still seen.
login goes through _replace_stored_token on both paths, so the credential
it replaces is revoked on the proxy and the user is still told where the
new one landed. logout revokes first, then reports what the clear actually
managed to do.

def _scrub_file_secret(record: CliTokenRecord) -> bool:
"""Leave no secret material in the token file once the vault holds it"""
if record.key is None and not record.jwt_token:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Refresh token remains after logout

A keychain-backed PKCE record has key=None and an empty jwt_token, so this returns without removing its refresh_token. If revocation returns PkceFailure and the keychain is unavailable, logout continues through this path and leaves the active refresh token in token.json; a process that later reads the file can redeem it for new access keys. Treat the refresh token as secret material—preferably storing it in the vault—and ensure the logout scrub removes it even when the metadata note must remain.

@mateo-berri
mateo-berri merged commit 8672cd4 into litellm_internal_staging Aug 20, 2026
75 of 76 checks passed
@mateo-berri
mateo-berri deleted the litellm_cli_refresh_tokens branch August 20, 2026 18:10
mateo-berri added a commit to BerriAI/litellm-docs that referenced this pull request Aug 20, 2026
* docs(cli): move the `lite login` credential to the OS keychain

`lite login` now stores the credential in the OS keychain and keeps only
non-secret metadata in ~/.litellm/token.json, falling back to that
owner-only file when no keychain is available. Update the CLI, SSO, and
identity provisioning pages, and document LITELLM_CLI_DISABLE_KEYRING.

See BerriAI/litellm#37566

* docs(cli): say the proxy extra leaves out keyring

The quick start offered litellm[proxy] as an equivalent way to get the
lite command, so a reader who took that path never reached a keychain
and nothing told them why.

Also names the SDK getter, which needs keyring for the same reason and
returns None without it even when lite login stored a credential.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants