Skip to content

fix(cli): keep the --pkce refresh token in the OS keychain, not in token.json - #37665

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_cli_keychain_refresh_token
Aug 20, 2026
Merged

fix(cli): keep the --pkce refresh token in the OS keychain, not in token.json#37665
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_cli_keychain_refresh_token

Conversation

@mateo-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

How it solves it:

  • The refresh token now goes into the keychain beside the key
  • Every silent renewal puts the new pair there too
  • An upgraded machine rejoins its two halves, then scrubs the file
  • A box with no keychain keeps both halves in one owner-only file

User Flow

Before: a developer signs in with lite login --pkce, and although their key is out of reach in the OS keychain, the refresh token that mints keys is in a cleartext file, so any program running as them can trade it for a working key

  1. The developer runs lite --base-url https://litellm-domain login --pkce, approves the sign-in in the browser, and sees Login successful! followed by Credential stored in your OS keychain.
  2. They send POST https://litellm-domain/v1/chat/completions through the CLI and get a 200 with a completion
  3. They run cat ~/.litellm/token.json and see no key, but a refresh_token in the clear next to their gateway URL and a token_endpoint saying where to send it
  4. A build script or editor extension running as that developer reads that file and sends POST https://litellm-domain/token with grant_type=refresh_token and that value, getting a 200 and a key of its own
  5. It sends POST https://litellm-domain/v1/chat/completions with that key and gets a 200 completion, billed to the developer and carrying their role
  6. A day later the developer's key expires, the next lite command renews it silently, and cat ~/.litellm/token.json shows the rotated refresh token sitting in the file exactly as before
  7. Copying that one file to another machine reproduces the developer's access there, with no keychain and no consent prompt in the way

After: the same sign-in puts both halves in the keychain, so the file has nothing left for that program to trade

  1. The developer runs lite --base-url https://litellm-domain login --pkce, approves the sign-in in the browser, and sees Login successful! followed by Credential stored in your OS keychain.
  2. They send POST https://litellm-domain/v1/chat/completions through the CLI and get a 200 with a completion
  3. They run cat ~/.litellm/token.json and see their gateway URL, user id, role, sign-in time, and endpoints, with no key and no refresh token in it
  4. A build script or editor extension reading that file finds nothing to send to POST https://litellm-domain/token, so there is no request for it to make
  5. A day later the developer's key expires, the next lite command renews it silently, and cat ~/.litellm/token.json still shows neither half; security find-generic-password -s litellm-cli -a credential -w on macOS, or Credential Manager on Windows, holds the renewed pair
  6. lite logout still sends POST https://litellm-domain/revoke first, so replaying that refresh token at POST https://litellm-domain/token afterwards comes back 400 invalid_grant
  7. A developer whose last sign-in was on the previous release is not asked to sign in again: their next command authenticates, the refresh token moves off disk into the keychain entry that already held their key, and renewal keeps working
  8. On a headless box with no keychain, the same sign-in says so and stores both halves in ~/.litellm/token.json, readable only by the developer, and everything keeps working

Before, a second program running as that developer could mint keys for their whole role for as long as the login lived, on any machine the token file was copied to. After, it finds nothing in the file to mint with, and the OS gates the keychain entry that holds the pair

Relevant issues

Docs for this change: BerriAI/litellm-docs#967

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
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • 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

Shared setup for every case below: a proxy built from the commit under test, on a random free port, with a real Postgres and a real Anthropic key, and a stub SSO provider standing in for the company IdP

$ cat config.yaml
model_list:
  - model_name: haiku
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

$ python litellm/proxy/proxy_cli.py --config config.yaml --port 9821 --num_workers 1

Each case runs in a throwaway $HOME with the litellm-cli keychain entry deleted first, so it starts from a machine that has never signed in. Two commands do the work in every case, the sign-in and one real provider call:

$ lite --base-url http://localhost:9821 login --pkce
$ lite --base-url http://localhost:9821 http request POST /v1/chat/completions \
    -j '{"model":"haiku","max_tokens":40,"messages":[{"role":"user","content":"Reply with exactly: QA5855 <case> <commit> authenticated this call"}]}'

The browser half of the sign-in, opening the authorize URL and approving the consent page, is driven with curl so the run is scriptable. The provider-call steps show the content field taken out of the 200 the proxy answered with, so the sentence in it names the case and the commit that call ran against. Two small helpers print what the token file and the keychain entry hold, truncating any secret to ...REDACTED..., and a third prints sha256 fingerprints (first 12 characters) so the same secret can be recognized across the two stores without printing it. The second process is this, a program with the developer's file access and nothing else:

$ cat scavenger.py
import json, os, pathlib, requests
rec = json.loads((pathlib.Path(os.environ["HOME"])/".litellm"/"token.json").read_text())
print("secret-looking fields on disk:", [k for k in ("key", "jwt_token", "refresh_token") if rec.get(k)])
print("bearer token it can read straight out of the file:", repr(rec.get("key") or ""))
r = requests.post(rec["token_endpoint"], data={
    "grant_type": "refresh_token", "refresh_token": rec["refresh_token"], "client_id": rec["client_id"],
}, timeout=15)
print("POST", rec["token_endpoint"], "with the file's refresh_token ->", r.status_code)
minted = r.json().get("access_token", "") if r.ok else ""
print("access token it minted:", (minted[:12] + "...REDACTED...") if minted else "(none)")
if minted:
    c = requests.post(rec["base_url"] + "/v1/chat/completions",
                      headers={"Authorization": "Bearer " + minted},
                      json={"model": "haiku", "max_tokens": 30,
                            "messages": [{"role": "user", "content": "stolen refresh token probe"}]}, timeout=60)
    print("chat completion with the minted key ->", c.status_code,
          "-- THE FILE ALONE STILL BUYS A WORKING CREDENTIAL" if c.ok else "-- rejected")

Before (edbb342)

lite login --pkce, and what another program running as the user can lift

  1. Sign in
Login successful!
Credential stored in your OS keychain.
  1. Read the token file the sign-in left behind
--- $HOME/.litellm/token.json ---
  base_url:             'http://localhost:9821'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787252096.858134
  expires_at:           1787338496.8581052
  refresh_token:        '...REDACTED...'
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
  1. Read the OS keychain entry the same way lite does
--- the OS keychain entry ---
keychain blob base_url:       http://localhost:9821
keychain blob key:            ...REDACTED...
keychain blob refresh_token:  (empty)
keychain blob timestamp:      1787252096.858134
preflight entry left over:    NO
  1. Make a real provider call on the credential
"content": "QA5855 PKCE edbb3429a3 authenticated this call"
  1. Run a second process that can read the user's files but not their keychain
secret-looking fields on disk: ['refresh_token']
bearer token it can read straight out of the file: ''
POST http://localhost:9821/token with the file's refresh_token -> 200
access token it minted: ...REDACTED...
chat completion with the minted key -> 200 -- THE FILE ALONE STILL BUYS A WORKING CREDENTIAL

Silent renewal, then lite logout

  1. Sign in again in a fresh $HOME and fingerprint both stores
  token.json  key=(none)        refresh_token=d26ff2afa64d  timestamp=1787252104.614069
  keychain   key=aa3e44ee03bf  refresh_token=(none)        timestamp=1787252104.614069
  1. Expire the key by hand, then make an ordinary provider call, which renews it silently
  expires_at set to 60 seconds ago; everything else untouched
"content": "QA5855 RENEWED edbb3429a3 authenticated this call"
  1. Look at where the renewed pair went
  renewed key is in the keychain:       YES
  renewed key is in token.json:         NO
  refresh token is in token.json:       YES
  token.json  key=(none)        refresh_token=47ba78c6b138  timestamp=1787252106.22711
  keychain   key=cffe67d5ecb7  refresh_token=(none)        timestamp=1787252106.22711
  1. Stash the live refresh token out of band, log out, and replay it
  (stashed the refresh token out of band, to replay it after logout)
$ lite --base-url http://localhost:9821 logout
Logged out successfully. Authentication token cleared.
--- both stores after logout ---
drwx------@ 2 mateo  wheel   64 Aug 20 11:55 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:55 ..
keychain slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- can the revoked refresh token still buy a key? ---
  POST http://localhost:9821/token -> 400
   {"error":"invalid_grant","error_description":"the refresh token was already used"}

A machine with no usable keychain

  1. Sign in with the keychain turned off
Login successful!
Keychain storage is off (LITELLM_CLI_DISABLE_KEYRING). Credential stored in $HOME/.litellm/token.json (owner-only).
  1. Look at the file, the keychain slot, and a real provider call on the credential
  -rw-------  $HOME/.litellm/token.json
  base_url:             'http://localhost:9821'
  key:                  '...REDACTED...'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787252112.64877
  expires_at:           1787338512.64848
  refresh_token:        '...REDACTED...'
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
keychain slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
"content": "QA5855 NO-KEYCHAIN edbb3429a3 authenticated this call"

A machine upgrading from the previous release

  1. Sign in, then run an ordinary command, and fingerprint what a second process can still reach
$ lite --base-url http://localhost:9821 whoami
Authenticated
User Email: unknown
User ID: qa5855_run9kc_user
User Role: cli
Token age: 0.0 hours
Key expires in: 24.0 hours, renewed on next use
--- fingerprints after that command (sha256, first 12) ---
  token.json  key=(none)        refresh_token=bbcf9d39989e  timestamp=1787252121.2757492
  keychain   key=62cba6794212  refresh_token=(none)        timestamp=1787252121.2757492
--- what a second process can still read out of the file ---
secret-looking fields on disk: ['refresh_token']
bearer token it can read straight out of the file: ''
POST http://localhost:9821/token with the file's refresh_token -> 200

After (0f1e09b)

lite login --pkce, and what another program running as the user can lift

  1. Sign in
Login successful!
Credential stored in your OS keychain.
  1. Read the token file the sign-in left behind
--- $HOME/.litellm/token.json ---
  base_url:             'http://localhost:9821'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787251479.257664
  expires_at:           1787337879.257642
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
  1. Read the OS keychain entry the same way lite does
--- the OS keychain entry, read in-process ---
keychain blob base_url:       http://localhost:9821
keychain blob key:            ...REDACTED...
keychain blob refresh_token:  ...REDACTED...
keychain blob timestamp:      1787251479.257664
preflight entry left over:    NO
  1. Run a second process that can read the user's files but not their keychain
secret-looking fields on disk: NONE
bearer token it can read straight out of the file: ''
no refresh_token on disk, so there is nothing to POST to http://localhost:9821/token
  1. Make a real provider call on the credential
"content": "QA5855 PKCE 0f1e09b555 authenticated this call"

Silent renewal, then lite logout

  1. Expire the key by hand, then make an ordinary provider call, which renews it silently
--- 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 0f1e09b555 authenticated this call"
  1. Look at where the renewed pair went
  renewed key is in the keychain:       YES
  renewed key is in token.json:         NO
  refresh token is in token.json:       NO
keychain blob base_url:       http://localhost:9821
keychain blob key:            ...REDACTED...
keychain blob refresh_token:  ...REDACTED...
keychain blob timestamp:      1787251483.7432919
preflight entry left over:    NO
  1. Stash the live refresh token out of band, log out, and replay it
  (stashed the refresh token out of band, to replay it after logout)
$ lite --base-url http://localhost:9821 logout
Logged out successfully. Authentication token cleared.
--- both stores after logout ---
drwx------@ 2 mateo  wheel   64 Aug 20 11:44 .
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:44 ..
keychain slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- can the revoked refresh token still buy a key? ---
  POST http://localhost:9821/token -> 400
   {"error":"invalid_grant","error_description":"the refresh token was already used"}

A machine with no usable keychain

  1. Sign in with the keychain turned off
Login successful!
Keychain storage is off (LITELLM_CLI_DISABLE_KEYRING). Credential stored in $HOME/.litellm/token.json (owner-only).
  1. Look at the file and the keychain slot
  -rw-------  $HOME/.litellm/token.json
  base_url:             'http://localhost:9821'
  key:                  '...REDACTED...'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787251807.264811
  expires_at:           1787338207.264778
  refresh_token:        '...REDACTED...'
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
keychain slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
  1. Make a real provider call on the credential
"content": "QA5855 NO-KEYCHAIN 0f1e09b555 authenticated this call"
  1. Expire the key by hand and renew from the file alone
  expires_at set to 60 seconds ago; everything else untouched
"content": "QA5855 NO-KEYCHAIN RENEWED 0f1e09b555 authenticated this call"
  -rw-------  $HOME/.litellm/token.json
  token.json  key=d71e192a54cb  refresh_token=b02b1d5b8892  timestamp=1787251810.453588
  keychain   key=(none)        refresh_token=(none)        timestamp=None
  1. Stash the live refresh token out of band, log out, and replay it
  (stashed the refresh token out of band, to replay it after logout)
$ lite --base-url http://localhost:9821 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.
drwxr-xr-x@ 4 mateo  wheel  128 Aug 20 11:50 ..
-rw-------@ 1 mateo  wheel  576 Aug 20 11:50 token.json
keychain slot: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
--- the file logout kept, with the secret taken out of it ---
  base_url:             'http://localhost:9821'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787251810.453588
  expires_at:           1787338210.4535801
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
--- can the revoked refresh token still buy a key? ---
  POST http://localhost:9821/token -> 400
   {"error":"invalid_grant","error_description":"the refresh token was already used"}

A machine upgrading from the previous release

  1. Sign in with the previous release's lite, so the machine starts in the state that release leaves behind
Login successful!
Credential stored in your OS keychain.
--- $HOME/.litellm/token.json, as the previous release left it ---
  base_url:             'http://localhost:9821'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787251741.624086
  expires_at:           1787338141.624031
  refresh_token:        '...REDACTED...'
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
--- the OS keychain entry, as the previous release left it ---
keychain blob base_url:       http://localhost:9821
keychain blob key:            ...REDACTED...
keychain blob refresh_token:  (empty)
keychain blob timestamp:      1787251741.624086
preflight entry left over:    NO
--- fingerprints of the two halves (sha256, first 12) ---
  token.json  key=(none)        refresh_token=ebf7ab6523ae  timestamp=1787251741.624086
  keychain   key=ffd060b3063d  refresh_token=(none)        timestamp=1787251741.624086
  1. Upgrade, then run one ordinary command
$ lite --base-url http://localhost:9821 whoami
Authenticated
User Email: unknown
User ID: qa5855_run9kc_user
User Role: cli
Token age: 0.0 hours
Key expires in: 24.0 hours, renewed on next use
--- $HOME/.litellm/token.json now ---
  base_url:             'http://localhost:9821'
  user_id:              'qa5855_run9kc_user'
  user_email:           'unknown'
  user_role:            'cli'
  auth_header_name:     'Authorization'
  jwt_token:            ''
  timestamp:            1787251741.624086
  expires_at:           1787338141.624031
  client_id:            '...REDACTED...'
  token_endpoint:       'http://localhost:9821/token'
  revocation_endpoint:  'http://localhost:9821/revoke'
  resource:             'http://localhost:9821'
--- the OS keychain entry now ---
keychain blob base_url:       http://localhost:9821
keychain blob key:            ...REDACTED...
keychain blob refresh_token:  ...REDACTED...
keychain blob timestamp:      1787251741.624086
preflight entry left over:    NO
--- fingerprints again: the same key, and the refresh token moved store ---
  token.json  key=(none)        refresh_token=(none)        timestamp=1787251741.624086
  keychain   key=ffd060b3063d  refresh_token=ebf7ab6523ae  timestamp=1787251741.624086
  1. Run a second process that can read the user's files but not their keychain
secret-looking fields on disk: NONE
bearer token it can read straight out of the file: ''
no refresh_token on disk, so there is nothing to POST to http://localhost:9821/token
  1. Make a real provider call on the rejoined credential
"content": "QA5855 UPGRADED 0f1e09b555 authenticated this call"
  1. Expire the key by hand, and renew on the refresh token that came off disk
  expires_at set to 60 seconds ago; everything else untouched
"content": "QA5855 UPGRADED-RENEWED 0f1e09b555 authenticated this call"
  token.json  key=(none)        refresh_token=(none)        timestamp=1787251752.03856
  keychain   key=6a6982cdbc23  refresh_token=0b1dfd40a310  timestamp=1787251752.03856

Type

🐛 Bug Fix

Caveats (if any)

  • A rejoin needs one keychain write to finish
  • A keychain that refuses writes keeps the refresh token on disk
  • LITELLM_CLI_DISABLE_KEYRING=1 boxes keep today's exposure
  • Two halves from different sign-ins are not merged
  • Renewal now needs keychain access, as the key already did
  • Going back to the previous release stops silent renewal
  • A keychain that refuses reads gives a vaguer hint

The upgrade path rejoins the two halves and then writes them back as one entry, so a machine whose keychain answers reads but refuses writes keeps its refresh token in token.json and tries again on the next command. That is the same pre-flight cost #37566 named for the key, and LITELLM_CLI_DISABLE_KEYRING=1 is still the way off it. Nothing is lost when the write fails: the file keeps what it had, and the key already in the keychain is left alone rather than rolled back

The rejoin only merges a file half and a keychain half that carry the same sign-in stamp, which is one login split across the two stores by the upgrade. Halves with different stamps are two different sign-ins, and the newer one wins whole, exactly as it did before this PR

A --pkce credential renews itself, and the refresh token that does it now lives where the key does, so an install that cannot reach the keychain cannot renew either. That is the same boundary the key was already behind: an install without the cli extra could not read the key it needed, and now it cannot read the refresh token either

A machine that takes this release and then goes back to the previous one has its refresh token in a store that release does not read, so silent renewal stops and lite logout no longer has the token it revokes with, which leaves that refresh token live on the gateway until it expires. Nothing is lost or corrupted: the previous release still reads the key out of the keychain and keeps serving requests on it, both stores stay exactly as they were, lite login --pkce puts the machine back on a renewing credential, and coming back to this release picks the pair up again and renews on it. Writing the refresh token to both stores through the transition would make the downgrade seamless, and it would also keep the cleartext copy this PR exists to remove, so the downgrade pays the cost instead

On a machine whose keychain cannot be read, two strings get vaguer. lite up says to run lite login where it used to say lite login --pkce, and lite whoami drops the ", renewed on next use" it appends to the expiry. Both read the same presence test, whether the record in hand carries a refresh token, and a keychain that cannot be read makes the answer no. The line directly above each of them already names the real problem, that the credential is in the keychain and something is blocking it, so the user is told what to fix either way

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
  • 0f1e09b5558160ad18db40387d63ebb90a1daf4a passes /live-pr-risk, with its findings recorded under Caveats

Note

High Risk
Changes how CLI auth secrets (including refresh tokens that mint new keys) are stored, migrated, and scrubbed. Incorrect rejoin or rollback logic could leak credentials or drop a live login.

Overview
Stops lite login --pkce from leaving the refresh token in ~/.litellm/token.json. The refresh token is now treated as secret material and stored in the OS keychain with the bearer key (or in the same 0600 file when there is no keychain).

On load, a split upgrade state (key already in the keychain, refresh token still on disk) is rejoined only when both halves share the same sign-in stamp, then migrated and scrubbed. Migration will not roll back a pre-existing keychain entry if the file cannot be rewritten. Different stamps still pick the newer login whole, so an old refresh token is never paired with a newer key.

CliTokenSecret.key is optional so a file that holds only a refresh token can move into the keychain without inventing a key. Logout still strips refresh tokens from disk even when the keychain cannot be cleared.

Reviewed by Cursor Bugbot for commit 0f1e09b. Bugbot is set up for automated code reviews on this repo. Configure here.

`lite login --pkce` mints a refresh token that buys a fresh key from the
proxy on demand, so it is the credential just as much as the key is. Moving
the key into the keychain left it behind in ~/.litellm/token.json, where any
process running as the user can read it and renew the login for itself.

It now travels with the key: `save_cli_token` writes both into the keychain
entry, the token file keeps only metadata, and `lite logout` takes it out of
the file whether or not the keychain answers.

Upgrading finds one sign-in split across the two stores, the key already in
the keychain and the refresh token still on disk. That case rejoins the two
halves into a single entry before scrubbing the file, so the write never
replaces a live key with nothing, and a machine that refuses the scrub keeps
what it has rather than having the key rolled back out from under it.
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens CLI credential storage by keeping PKCE refresh tokens alongside access keys in the OS keychain while retaining the owner-only file fallback.

  • Extends keychain payloads and secret scrubbing to include refresh tokens.
  • Rejoins matching credentials split across the keychain and token file during upgrades.
  • Adds migration, renewal, fallback, and failure-path tests.
  • Updates the CLI authentication storage documentation.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or non-blocking defect identified in the changed behavior.

The new refresh-token handling preserves coherent credentials across successful keychain storage, legacy migration, renewal, unavailable-keychain fallback, and documented file-scrubbing failures.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/cli_token_utils.py Moves refresh tokens through the existing keychain lifecycle, including save, load, migration, arbitration, scrubbing, fallback, and upgrade rejoin behavior; no actionable defect was established.
tests/test_litellm/litellm_core_utils/test_cli_token_utils.py Adds focused coverage for refresh-token round trips, legacy split-store upgrades, fallback storage, and keychain/file failure branches without weakening existing assertions.
litellm/proxy/client/README.md Updates the in-tree CLI authentication documentation to describe keychain storage for PKCE refresh tokens and the owner-only fallback.

Reviews (1): Last reviewed commit: "fix(cli): keep the refresh token in the ..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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 0f1e09b. Configure here.

@mateo-berri
mateo-berri enabled auto-merge August 20, 2026 19:30
@mateo-berri
mateo-berri merged commit d491a3d into litellm_internal_staging Aug 20, 2026
74 checks passed
@mateo-berri
mateo-berri deleted the litellm_cli_keychain_refresh_token branch August 20, 2026 19:34
@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_keychain_refresh_token (0f1e09b) with litellm_internal_staging (d542c82)1

Open in CodSpeed

Footnotes

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

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.

2 participants