After the supply chain incident in March, we brought in Veria Labs to audit the LiteLLM proxy and fixed a number of vulnerability reports from independent researchers. All issues below are fixed in v1.83.0. If you are affected, particularly if you have JWT auth enabled, we recommend upgrading.
We've also launched a bug bounty program and Veria Labs is continuing to audit the proxy. More fixes will ship in upcoming versions.
The two high-severity issues (CVE-2026-35029 and GHSA-69x8-hrgq-fjj8) both require the attacker to already have a valid API key for the proxy. These are not exploitable by unauthenticated users.
The critical-severity issue (CVE-2026-35030) is an authentication bypass, but only affects deployments with enable_jwt_auth explicitly enabled, which is off by default. The default LiteLLM configuration is not affected, and no LiteLLM Cloud customers had this feature enabled.
Building on the roadmap from our security incident, CI/CD v2 introduces isolated environments, stronger security gates, and safer release separation for LiteLLM.
Security scans and unit tests run in isolated environments.
Validation and release are separated into different repositories, making it harder for an attacker to reach release credentials.
Trusted Publishing for PyPI releases - this means no long-lived credentials are used to publish releases.
Immutable Docker release tags - this means no tampering of Docker release tags after they are published Learn more. Note: work for GHCR docker releases is planned as well.
Docker image signing with Cosign - all release images are signed so users can independently verify they came from us.
Starting from v1.83.0-nightly, all LiteLLM Docker images published to GHCR are signed with cosign. Every release is signed with the same key introduced in commit 0112e53.
Verify using the pinned commit hash (recommended):
A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:
Replace <release-tag> with the version you are deploying (e.g. v1.83.0-stable).
Expected output:
The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key
Adopting OpenSSF (this is a set of security criteria that projects should meet to demonstrate a strong security posture - Learn more)
We've added Scorecard and Allstar to our Github
Adding SLSA Build Provenance to our CI/CD pipeline - this means we allow users to independently verify that a release came from us and prevent silent modifications of releases after they are published.
We hope that this will mean you can be confident that the releases you are using are safe and from us.
We wanted to use that time to walk through what we know, what we've done so far, and how we're improving LiteLLM's release and security processes going forward. This post is a written version of that update. Slides available here
Status: Active investigation
Last updated: March 27, 2026
Update (March 30): A new clean version of LiteLLM is now available (v1.83.0). This was released by our new CI/CD v2 pipeline which added isolated environments, stronger security gates, and safer release separation for LiteLLM.
Update (March 27): Review Townhall updates, including explanation of the incident, what we've done, and what comes next. Learn more
Update (March 27): Added Verified safe versions section with SHA-256 checksums for all audited PyPI and Docker releases.
Update (March 25): Added community-contributed scripts for scanning GitHub Actions and GitLab CI pipelines for the compromised versions. See How to check if you are affected. s/o @Zach Fury for these scripts.
The compromised PyPI packages were litellm==1.82.7 and litellm==1.82.8. Those packages were live on March 24, 2026 from 10:39 UTC for about 40 minutes before being quarantined by PyPI.
We believe that the compromise originated from the Trivy dependency used in our CI/CD security scanning workflow.
Customers running the official LiteLLM Proxy Docker image were not impacted. That deployment path pins dependencies in requirements.txt and does not rely on the compromised PyPI packages.
We have paused all new LiteLLM releases until we complete a broader supply-chain review and confirm the release path is safe.Updated: We have now released a new safe version of LiteLLM (v1.83.0) by our new CI/CD v2 pipeline which added isolated environments, stronger security gates, and safer release separation for LiteLLM. We have also verified the codebase is safe and no malicious code was pushed to main.
LiteLLM AI Gateway is investigating a suspected supply chain attack involving unauthorized PyPI package publishes. Current evidence suggests a maintainer's PyPI account may have been compromised and used to distribute malicious code.
At this time, we believe this incident may be linked to the broader Trivy security compromise, in which stolen credentials were reportedly used to gain unauthorized access to the LiteLLM publishing pipeline.
This investigation is ongoing. Details below may change as we confirm additional findings.
You may be affected if any of the following are true:
You installed or upgraded LiteLLM via pip on March 24, 2026, between 10:39 UTC and 16:00 UTC
You ran pip install litellm without pinning a version and received v1.82.7 or v1.82.8
You built a Docker image during this window that included pip install litellm without a pinned version
A dependency in your project pulled in LiteLLM as a transitive, unpinned dependency
(for example through AI agent frameworks, MCP servers, or LLM orchestration tools)
You are not affected if any of the following are true:
LiteLLM AI Gateway/Proxy users: Customers running the official LiteLLM Proxy Docker image were not impacted. That deployment path pins dependencies in requirements.txt and does not rely on the compromised PyPI packages.
You are using LiteLLM Cloud
You are using the official LiteLLM AI Gateway Docker image: ghcr.io/berriai/litellm
You are on v1.82.6 or earlier and did not upgrade during the affected window
You installed LiteLLM from source via the GitHub repository, which was not compromised
Go to the proxy base url, and check the version of the installed LiteLLM.
Scans all repositories in a GitHub organization for workflow jobs that installed the compromised versions.
Requirements: Python 3 and requests (pip install requests).
Setup:
export GITHUB_TOKEN="your-github-pat"
Run:
python find_litellm_github.py
Set the ORG variable in the script to your GitHub organization name.
Both scripts default to scanning jobs from today. Adjust the WINDOW_START and WINDOW_END constants to cover March 24, 2026 (the incident date) if running on a different day.
View full script (find_litellm_github.py)
#!/usr/bin/env python3 """ Scan all GitHub Actions jobs in a GitHub org that ran between 0800-1244 UTC today and identify any that installed litellm 1.82.7 or 1.82.8. Adjust WINDOW_START / WINDOW_END to cover March 24, 2026 if running later. """ import io import os import re import sys import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import requests GITHUB_URL ="https://api.github.com" ORG ="your-org"# <-- set to your GitHub organization TOKEN = os.environ.get("GITHUB_TOKEN","") TODAY = datetime.now(timezone.utc).date() WINDOW_START = datetime(TODAY.year, TODAY.month, TODAY.day,8,0,0, tzinfo=timezone.utc) WINDOW_END = datetime(TODAY.year, TODAY.month, TODAY.day,12,44,0, tzinfo=timezone.utc) TARGET_VERSIONS ={"1.82.7","1.82.8"} VERSION_PATTERN = re.compile(r"litellm[=\-](\d+\.\d+\.\d+)", re.IGNORECASE) SESSION = requests.Session() SESSION.headers.update({ "Authorization":f"Bearer {TOKEN}", "Accept":"application/vnd.github+json", "X-GitHub-Api-Version":"2022-11-28", }) defget_paginated(url, params=None): params =dict(params or{}) params.setdefault("per_page",100) page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) if resp.status_code ==404: return resp.raise_for_status() data = resp.json() ifisinstance(data,dict): items =next((v for v in data.values()ifisinstance(v,list)),[]) else: items = data ifnot items: break yieldfrom items iflen(items)< params["per_page"]: break page +=1 defparse_ts(ts_str): ifnot ts_str: returnNone return datetime.fromisoformat(ts_str.replace("Z","+00:00")) defget_repos(): repos =[] for r in get_paginated(f"{GITHUB_URL}/orgs/{ORG}/repos",{"type":"all"}): repos.append({"id": r["id"],"name": r["name"],"full_name": r["full_name"]}) return repos defget_runs_in_window(repo_full_name): created_filter =( f"{WINDOW_START.strftime('%Y-%m-%dT%H:%M:%SZ')}" f"..{WINDOW_END.strftime('%Y-%m-%dT%H:%M:%SZ')}" ) url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/runs" runs =[] for run in get_paginated(url,{"created": created_filter,"per_page":100}): ts = parse_ts(run.get("run_started_at")or run.get("created_at")) if ts and WINDOW_START <= ts <= WINDOW_END: runs.append(run) return runs defget_jobs_for_run(repo_full_name, run_id): url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/runs/{run_id}/jobs" jobs =[] for job in get_paginated(url,{"filter":"all"}): ts = parse_ts(job.get("started_at")) if ts and WINDOW_START <= ts <= WINDOW_END: jobs.append(job) return jobs deffetch_job_log(repo_full_name, job_id): url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/jobs/{job_id}/logs" resp = SESSION.get(url, timeout=60, allow_redirects=True) if resp.status_code in(403,404,410): return"" resp.raise_for_status() content_type = resp.headers.get("Content-Type","") if"zip"in content_type or resp.content[:2]==b"PK": try: with zipfile.ZipFile(io.BytesIO(resp.content))as zf: parts =[] for name insorted(zf.namelist()): with zf.open(name)as f: parts.append(f.read().decode("utf-8", errors="replace")) return"\n".join(parts) except zipfile.BadZipFile: pass return resp.text defcheck_job(repo_full_name, job): job_id = job["id"] job_name = job["name"] run_id = job["run_id"] started = job.get("started_at","") log_text = fetch_job_log(repo_full_name, job_id) ifnot log_text: returnNone found_versions =set() context_lines =[] for line in log_text.splitlines(): m = VERSION_PATTERN.search(line) if m: ver = m.group(1) if ver in TARGET_VERSIONS: found_versions.add(ver) context_lines.append(line.strip()) ifnot found_versions: returnNone return{ "repo": repo_full_name, "run_id": run_id, "job_id": job_id, "job_name": job_name, "started_at": started, "versions":sorted(found_versions), "context": context_lines[:10], "job_url": job.get("html_url",f"https://github.com/{repo_full_name}/actions/runs/{run_id}"), } defmain(): ifnot TOKEN: print("ERROR: Set GITHUB_TOKEN environment variable.",file=sys.stderr) sys.exit(1) print(f"Time window : {WINDOW_START.isoformat()} -> {WINDOW_END.isoformat()}") print(f"Hunting for : litellm {', '.join(sorted(TARGET_VERSIONS))}") print() print(f"Fetching repositories for org '{ORG}'...") repos = get_repos() print(f" Found {len(repos)} repositories") print() jobs_to_check =[] print("Scanning workflow runs for time window...") for repo in repos: full_name = repo["full_name"] try: runs = get_runs_in_window(full_name) except requests.HTTPError as e: print(f" WARN: {full_name} - {e}",file=sys.stderr) continue ifnot runs: continue print(f" {full_name}: {len(runs)} run(s) in window") for run in runs: try: jobs = get_jobs_for_run(full_name, run["id"]) except requests.HTTPError as e: print(f" WARN: run {run['id']} - {e}",file=sys.stderr) continue for job in jobs: jobs_to_check.append((full_name, job)) total =len(jobs_to_check) print(f"\nFetching logs for {total} job(s)...") print() hits =[] with ThreadPoolExecutor(max_workers=8)as pool: futures ={ pool.submit(check_job, full_name, job):(full_name, job["id"]) for full_name, job in jobs_to_check } done =0 for future in as_completed(futures): done +=1 full_name, jid = futures[future] try: result = future.result() except Exception as e: print(f" ERROR {full_name} job {jid}: {e}",file=sys.stderr) continue if result: hits.append(result) print( f" [{done}/{total}] {full_name} job {jid}"+ (f" *** HIT: litellm {result['versions']} ***"if result else""), flush=True, ) print() print("="*72) print(f"RESULTS: {len(hits)} job(s) installed litellm {' or '.join(sorted(TARGET_VERSIONS))}") print("="*72) ifnot hits: print("No matches found.") return for h insorted(hits, key=lambda x: x["started_at"]): print() print(f" Repo : {h['repo']}") print(f" Job : {h['job_name']} (#{h['job_id']})") print(f" Run ID : {h['run_id']}") print(f" Started : {h['started_at']}") print(f" Versions : litellm {', '.join(h['versions'])}") print(f" URL : {h['job_url']}") print(f" Log lines :") for line in h["context"]: print(f" {line}") if __name__ =="__main__": main()
Scans all projects in a GitLab group (including subgroups) for CI/CD jobs that installed the compromised versions.
Requirements: Python 3 and requests (pip install requests).
Setup:
export GITLAB_TOKEN="your-gitlab-pat"
Run:
python find_litellm_jobs.py
Set the GROUP_NAME variable in the script to your GitLab group name.
Both scripts default to scanning jobs from today. Adjust the WINDOW_START and WINDOW_END constants to cover March 24, 2026 (the incident date) if running on a different day.
View full script (find_litellm_jobs.py)
#!/usr/bin/env python3 """ Scan all GitLab CI/CD jobs in a GitLab group that ran between 0800-1244 UTC today and identify any that installed litellm 1.82.7 or 1.82.8. Adjust WINDOW_START / WINDOW_END to cover March 24, 2026 if running later. """ import os import re import sys from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import requests GITLAB_URL ="https://gitlab.com" GROUP_NAME ="YourGroup"# <-- set to your GitLab group name TOKEN = os.environ.get("GITLAB_TOKEN","") TODAY = datetime.now(timezone.utc).date() WINDOW_START = datetime(TODAY.year, TODAY.month, TODAY.day,8,0,0, tzinfo=timezone.utc) WINDOW_END = datetime(TODAY.year, TODAY.month, TODAY.day,12,44,0, tzinfo=timezone.utc) TARGET_VERSIONS ={"1.82.7","1.82.8"} VERSION_PATTERN = re.compile(r"litellm[=\-](\d+\.\d+\.\d+)", re.IGNORECASE) HEADERS ={"PRIVATE-TOKEN": TOKEN} SESSION = requests.Session() SESSION.headers.update(HEADERS) defget_paginated(url, params=None): params =dict(params or{}) params.setdefault("per_page",100) page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) resp.raise_for_status() data = resp.json() ifnot data: break yieldfrom data iflen(data)< params["per_page"]: break page +=1 defget_group_id(group_name): resp = SESSION.get(f"{GITLAB_URL}/api/v4/groups/{group_name}", timeout=30) resp.raise_for_status() return resp.json()["id"] defget_all_projects(group_id): projects =[] for p in get_paginated( f"{GITLAB_URL}/api/v4/groups/{group_id}/projects", {"include_subgroups":"true","archived":"false"}, ): projects.append({"id": p["id"],"name": p["path_with_namespace"]}) return projects defparse_ts(ts_str): ifnot ts_str: returnNone ts_str = ts_str.replace("Z","+00:00") return datetime.fromisoformat(ts_str) defjobs_in_window(project_id): matching =[] url =f"{GITLAB_URL}/api/v4/projects/{project_id}/jobs" params ={"per_page":100,"scope[]":["success","failed","canceled","running"]} page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) if resp.status_code ==403: return matching resp.raise_for_status() jobs = resp.json() ifnot jobs: break stop_early =False for job in jobs: ts = parse_ts(job.get("started_at")or job.get("created_at")) if ts isNone: continue if ts > WINDOW_END: continue if ts < WINDOW_START: stop_early =True continue matching.append(job) if stop_early orlen(jobs)<100: break page +=1 return matching deffetch_trace(project_id, job_id): url =f"{GITLAB_URL}/api/v4/projects/{project_id}/jobs/{job_id}/trace" resp = SESSION.get(url, timeout=60) if resp.status_code in(403,404): return"" resp.raise_for_status() return resp.text defcheck_job(project_name, project_id, job): job_id = job["id"] job_name = job["name"] ref = job.get("ref","") started = job.get("started_at", job.get("created_at","")) trace = fetch_trace(project_id, job_id) ifnot trace: returnNone found_versions =set() formatchin VERSION_PATTERN.finditer(trace): ver =match.group(1) if ver in TARGET_VERSIONS: found_versions.add(ver) ifnot found_versions: returnNone context_lines =[] for line in trace.splitlines(): if VERSION_PATTERN.search(line): ver_match = VERSION_PATTERN.search(line) if ver_match and ver_match.group(1)in TARGET_VERSIONS: context_lines.append(line.strip()) return{ "project": project_name, "project_id": project_id, "job_id": job_id, "job_name": job_name, "ref": ref, "started_at": started, "versions":sorted(found_versions), "context": context_lines[:10], "job_url":f"{GITLAB_URL}/{project_name}/-/jobs/{job_id}", } defmain(): ifnot TOKEN: print("ERROR: Set GITLAB_TOKEN environment variable.",file=sys.stderr) sys.exit(1) print(f"Time window : {WINDOW_START.isoformat()} -> {WINDOW_END.isoformat()}") print(f"Hunting for : litellm {', '.join(sorted(TARGET_VERSIONS))}") print() print(f"Resolving group '{GROUP_NAME}'...") group_id = get_group_id(GROUP_NAME) print("Fetching projects...") projects = get_all_projects(group_id) print(f" Found {len(projects)} projects") print() all_jobs_to_check =[] print("Scanning job listings for time window...") for proj in projects: try: jobs = jobs_in_window(proj["id"]) except requests.HTTPError as e: print(f" WARN: {proj['name']} - {e}",file=sys.stderr) continue if jobs: print(f" {proj['name']}: {len(jobs)} job(s) in window") for j in jobs: all_jobs_to_check.append((proj["name"], proj["id"], j)) total =len(all_jobs_to_check) print(f"\nFetching traces for {total} job(s)...") print() hits =[] with ThreadPoolExecutor(max_workers=10)as pool: futures ={ pool.submit(check_job, pname, pid, job):(pname, job["id"]) for pname, pid, job in all_jobs_to_check } done =0 for future in as_completed(futures): done +=1 pname, jid = futures[future] try: result = future.result() except Exception as e: print(f" ERROR checking {pname} job {jid}: {e}",file=sys.stderr) continue if result: hits.append(result) print(f" [{done}/{total}] checked {pname} job {jid}"+ (f" *** HIT: litellm {result['versions']} ***"if result else""), flush=True) print() print("="*72) print(f"RESULTS: {len(hits)} job(s) installed litellm {' or '.join(sorted(TARGET_VERSIONS))}") print("="*72) ifnot hits: print("No matches found.") return for h insorted(hits, key=lambda x: x["started_at"]): print() print(f" Project : {h['project']}") print(f" Job : {h['job_name']} (#{h['job_id']})") print(f" Branch/tag: {h['ref']}") print(f" Started : {h['started_at']}") print(f" Versions : litellm {', '.join(h['versions'])}") print(f" URL : {h['job_url']}") print(f" Log lines :") for line in h["context"]: print(f" {line}") if __name__ =="__main__": main()
CI/CD scripts contributed by the community (original gist). Review before running.
Starting from v1.83.0-nightly, all LiteLLM Docker images published to GHCR are signed with cosign. Every release is signed with the same key introduced in commit 0112e53.
Verify using the pinned commit hash (recommended):
A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:
Replace <release-tag> with the version you are deploying (e.g. v1.83.0-stable).
Expected output:
The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key
When a custom guardrail returned the full LiteLLM request/data dictionary, the guardrail response logged by LiteLLM could include secret_fields.raw_headers, including plaintext Authorization headers containing API keys or other credentials.
This information could then propagate to logging and observability surfaces that consume guardrail metadata, including:
Spend logs in the LiteLLM UI: visible to admins with access to spend-log data
OpenTelemetry traces: visible to anyone with access to the relevant telemetry backend
LLM calls, proxy routing, and provider execution were not blocked by this bug. The impact was exposure of sensitive request headers in observability and logging paths.