How to Scrape Indeed in 2026: Job Listings, Salaries, and Company Reviews

Indeed is the largest job board in the world with over 350 million unique visitors per month. Whether you’re building a job aggregator, tracking salary trends, or doing labor market research, Indeed’s data is incredibly valuable.

But Indeed doesn’t offer a public API. And their anti-bot systems are among the most aggressive I’ve tested.

In this guide, I’ll show you how to scrape Indeed job listings, salary data, and company reviews using Python — and how to handle the anti-bot measures that will try to stop you.

What Data Can You Extract from Indeed?

Indeed has three main data types worth scraping:

  • Job listings: Title, company, location, salary range, job type (full-time/part-time/contract), posted date, job description
  • Salary data: Average salaries by job title and location, salary ranges, pay transparency info
  • Company reviews: Overall rating, work-life balance, compensation, management, culture scores, review text

The Anti-Bot Problem

Indeed uses multiple layers of protection:

  1. Cloudflare WAF — blocks suspicious request patterns
  2. JavaScript challenges — requires browser-like JS execution
  3. Rate limiting — aggressive throttling after a few dozen requests
  4. CAPTCHA walls — triggered by unusual traffic patterns
  5. Session fingerprinting — tracks browser characteristics across requests

A simple requests.get() will get you blocked within 5-10 requests. You need a real strategy.

Method 1: Basic Scraping with requests + BeautifulSoup

This works for small-scale scraping (under 50 pages). You’ll need proper headers and delays.

import requests
from bs4 import BeautifulSoup
import time
import random
import json


def scrape_indeed_jobs(query: str, location: str, pages: int = 3) -> list[dict]:
    """Scrape Indeed job listings for a given query and location."""
    jobs = []
    base_url = "https://www.indeed.com/jobs"

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/125.0.0.0 Safari/537.36",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
        "Accept-Encoding": "gzip, deflate, br",
        "Referer": "https://www.indeed.com/",
        "DNT": "1",
    }

    session = requests.Session()
    session.headers.update(headers)

    # Visit homepage first to get cookies
    session.get("https://www.indeed.com", timeout=10)
    time.sleep(random.uniform(2, 4))

    for page in range(pages):
        params = {
            "q": query,
            "l": location,
            "start": page * 10,
            "sort": "date",
        }

        try:
            response = session.get(base_url, params=params, timeout=15)
            response.raise_for_status()
        except requests.RequestException as e:
            print(f"Request failed on page {page}: {e}")
            break

        soup = BeautifulSoup(response.text, "html.parser")

        # Indeed embeds job data in a script tag as JSON
        script_tag = soup.find("script", {"id": "mosaic-data"})
        if script_tag:
            jobs.extend(parse_mosaic_data(script_tag.string))
        else:
            # Fallback: parse HTML directly
            job_cards = soup.select("div.job_seen_beacon")
            for card in job_cards:
                job = extract_job_from_card(card)
                if job:
                    jobs.append(job)

        # Random delay between pages (critical for avoiding blocks)
        time.sleep(random.uniform(3, 7))

    return jobs


def extract_job_from_card(card) -> dict | None:
    """Extract job data from an Indeed job card HTML element."""
    title_elem = card.select_one("h2.jobTitle a")
    company_elem = card.select_one('span[data-testid="company-name"]')
    location_elem = card.select_one('div[data-testid="text-location"]')
    salary_elem = card.select_one("div.salary-snippet-container")
    snippet_elem = card.select_one("div.job-snippet")
    date_elem = card.select_one("span.date")
    job_type_elem = card.select_one('div[data-testid="attribute_snippet_testid"]')

    if not title_elem:
        return None

    return {
        "title": title_elem.get_text(strip=True),
        "company": company_elem.get_text(strip=True) if company_elem else None,
        "location": location_elem.get_text(strip=True) if location_elem else None,
        "salary": salary_elem.get_text(strip=True) if salary_elem else None,
        "job_type": job_type_elem.get_text(strip=True) if job_type_elem else None,
        "snippet": snippet_elem.get_text(strip=True) if snippet_elem else None,
        "posted": date_elem.get_text(strip=True) if date_elem else None,
        "url": "https://www.indeed.com" + title_elem["href"] if title_elem.get("href") else None,
    }


def parse_mosaic_data(script_content: str) -> list[dict]:
    """Parse the mosaic-data JSON that Indeed embeds in search pages."""
    jobs = []
    try:
        data = json.loads(script_content)
        results = (
            data.get("mosaic", {})
            .get("providerData", {})
            .get("jobListing", {})
            .get("results", [])
        )
        for result in results:
            jobs.append({
                "title": result.get("title"),
                "company": result.get("company"),
                "location": result.get("formattedLocation"),
                "salary": result.get("salarySnippet", {}).get("text"),
                "job_type": result.get("jobTypes", [None])[0] if result.get("jobTypes") else None,
                "snippet": result.get("snippet"),
                "posted": result.get("formattedRelativeTime"),
                "url": f"https://www.indeed.com/viewjob?jk={result.get('jobkey')}",
            })
    except (json.JSONDecodeError, KeyError):
        pass
    return jobs


# Usage
if __name__ == "__main__":
    results = scrape_indeed_jobs("python developer", "New York, NY", pages=3)
    print(f"Found {len(results)} jobs")
    for job in results[:5]:
        print(f"  {job['title']} at {job['company']} — {job['salary'] or 'No salary listed'}")

Method 2: Scraping Salary Pages

Indeed has dedicated salary pages at indeed.com/career/{job-title}/salaries. These are less protected than job search:

def scrape_indeed_salaries(job_title: str, location: str = "") -> dict:
    """Scrape salary data for a specific job title from Indeed."""
    slug = job_title.lower().replace(" ", "-")
    url = f"https://www.indeed.com/career/{slug}/salaries"
    if location:
        url += f"/{location.lower().replace(' ', '-').replace(',', '')}".replace("--", "-")

    headers = {
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/125.0.0.0 Safari/537.36",
    }

    response = requests.get(url, headers=headers, timeout=15)
    soup = BeautifulSoup(response.text, "html.parser")

    salary_data = {"job_title": job_title, "location": location}

    # Extract average salary
    avg_elem = soup.select_one('div[data-testid="avg-salary-value"]')
    if avg_elem:
        salary_data["average_salary"] = avg_elem.get_text(strip=True)

    # Extract salary range
    range_elem = soup.select_one('div[data-testid="salary-range"]')
    if range_elem:
        salary_data["salary_range"] = range_elem.get_text(strip=True)

    # Extract top-paying companies
    company_elems = soup.select('a[data-testid="top-company-salary"] span')
    salary_data["top_companies"] = [el.get_text(strip=True) for el in company_elems[:5]]

    return salary_data


# Usage
salary = scrape_indeed_salaries("software engineer", "San Francisco CA")
print(f"Average: {salary.get('average_salary', 'N/A')}")
print(f"Range: {salary.get('salary_range', 'N/A')}")

Method 3: Scraping Company Reviews

Company reviews live at indeed.com/cmp/{company-name}/reviews:

def scrape_indeed_reviews(company_slug: str, pages: int = 3) -> list[dict]:
    """Scrape company reviews from Indeed."""
    reviews = []
    base_url = f"https://www.indeed.com/cmp/{company_slug}/reviews"

    session = requests.Session()
    session.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/125.0.0.0 Safari/537.36",
    })

    for page in range(1, pages + 1):
        params = {"start": (page - 1) * 20}
        response = session.get(base_url, params=params, timeout=15)
        soup = BeautifulSoup(response.text, "html.parser")

        review_cards = soup.select('div[data-testid="review-card"]')
        for card in review_cards:
            title = card.select_one('h2[data-testid="review-title"]')
            rating = card.select_one('button[aria-label*="out of 5 stars"]')
            body = card.select_one('span[data-testid="review-text"]')
            author = card.select_one('span[data-testid="review-author"]')
            date = card.select_one('span[data-testid="review-date"]')

            reviews.append({
                "title": title.get_text(strip=True) if title else None,
                "rating": rating["aria-label"].split()[0] if rating else None,
                "text": body.get_text(strip=True) if body else None,
                "author": author.get_text(strip=True) if author else None,
                "date": date.get_text(strip=True) if date else None,
            })

        time.sleep(random.uniform(3, 6))

    return reviews

Scaling Up: When Basic Scraping Isn’t Enough

The code above works for small projects — maybe 100-200 pages per day. But if you need to track thousands of job listings across multiple cities, you’ll hit Indeed’s anti-bot walls fast.

The main problems at scale:

  • IP bans after 50-100 requests from the same IP
  • CAPTCHA challenges that break automated flows
  • JavaScript rendering that requests can’t handle
  • Session invalidation that kills your cookies mid-scrape

Using ScraperAPI for Scale

ScraperAPI handles the hard parts — proxy rotation, CAPTCHA solving, browser fingerprinting — so you can focus on parsing the data:

SCRAPERAPI_KEY = "your_api_key_here"


def scrape_with_scraperapi(url: str) -> str:
    """Fetch a page through ScraperAPI with anti-bot bypass."""
    params = {
        "api_key": SCRAPERAPI_KEY,
        "url": url,
        "render": "true",         # JavaScript rendering
        "country_code": "us",     # US residential IPs
        "premium": "true",        # Premium proxy pool (needed for Indeed)
    }
    response = requests.get("http://api.scraperapi.com", params=params, timeout=60)
    return response.text


# Use it in your scraping function
html = scrape_with_scraperapi(
    "https://www.indeed.com/jobs?q=data+engineer&l=Austin%2C+TX"
)
soup = BeautifulSoup(html, "html.parser")
# ... parse as before

ScraperAPI automatically rotates through millions of residential IPs, solves CAPTCHAs, and handles JavaScript rendering. You pay per successful request instead of managing proxy infrastructure yourself.

Storing and Analyzing Job Data

Once you have the data, store it properly for analysis:

import csv
from datetime import datetime


def save_jobs_to_csv(jobs: list[dict], filename: str = "indeed_jobs.csv"):
    """Save scraped jobs to CSV with timestamp."""
    fieldnames = [
        "title", "company", "location", "salary",
        "job_type", "snippet", "posted", "url", "scraped_at",
    ]

    with open(filename, "a", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        if f.tell() == 0:
            writer.writeheader()
        for job in jobs:
            job["scraped_at"] = datetime.now().isoformat()
            writer.writerow(job)

    print(f"Saved {len(jobs)} jobs to {filename}")

Key Takeaways

  1. Start with the embedded JSON — Indeed’s mosaic-data script tag is more reliable than parsing HTML
  2. Rotate user agents and add delays — minimum 3-5 seconds between requests
  3. Use sessions — visit the homepage first to collect cookies before searching
  4. Salary and review pages are less protected than job search — start there if you need that data
  5. For anything over 200 pages/day, use a proxy service like ScraperAPI to handle anti-bot detection

Related Tools

If you’re doing job market research at scale, check out the LinkedIn Jobs Scraper on Apify — it handles LinkedIn’s anti-bot measures and returns structured data you can combine with your Indeed scrapes.

Need to scrape other job boards? I’m building more scrapers on my Apify profile. Follow for updates.

Understanding the Distinctions: PowerShell vs. Azure CLI

Introduction

In today’s cloud computing era, Microsoft Azure is known as one of the leading cloud providers, offering a range of services to meet businesses’ dynamic needs.

Two powerful tools frequently used in the Azure ecosystem are PowerShell and Azure Command-Line Interface (CLI).

While both serve the common goal of managing and automating tasks in Azure, they differ significantly in their approach, syntax, and capabilities.

So, let’s explore these two by starting with PowerShell, then the Azure CLI.

Hopefully, by the end of the article, you’ll agree that both of these tools should be learned when working with Azure.

PowerShell: The Power Behind Scripting

Microsoft developed PowerShell, a task automation framework and scripting language.

It is a powerful command-line interface and a scripting environment that enables users to automate tasks and manage configurations.

One of the standout features of PowerShell is its object-oriented nature, which allows the output of one command to be used seamlessly as input to another.

This object-oriented approach makes PowerShell scripts highly versatile and flexible.

Let’s discuss some attributes of PowerShell below.

Cross-Platform Support

Before we discuss other PowerShell attributes, I think it is worth noting that PowerShell is cross-platform.

However, it was initially designed for Windows environments, and PowerShell has evolved to support cross-platform use through PowerShell Core (now known as PowerShell 7), allowing users to run PowerShell on Linux and macOS, broadening its applicability across operating systems.

That’s why it is recommended to install PowerShell version 7 side by side with PowerShell (Windows).

Syntax and Scripting

For readability, PowerShell uses a Verb-Noun naming convention for its commands, which provides a consistent structure.

For example, cmdlets like Get-AzVM or New-AzResourceGroup follow this convention.

PowerShell for Windows, its scripting language, is built on the .NET framework.

In contrast, PowerShell Core (now known as PowerShell 7) is built on .NET Core, making it a robust, extensible tool for automating and managing Azure resources.
Integration with Azure

We can integrate Azure directly from PowerShell by installing the Azure PowerShell module, which provides cmdlets for various Azure services, making it a preferred choice for Windows-centric environments and users familiar with PowerShell scripting.

Integration with Azure

We can integrate Azure directly from PowerShell by installing the Azure PowerShell module, which provides cmdlets for various Azure services, making it a preferred choice for Windows-centric environments and users familiar with PowerShell scripting.

If you would like to know more about installing the Azure PowerShell module, here are some links for guidance:
• https://github.com/Azure/azure-powershell
• https://learn.microsoft.com/en-us/powershell/azure/install-azps-windows?view=azps-15.4.0&tabs=windowspowershell&pivots=windows-psgallery
• https://learn.microsoft.com/en-us/powershell/azure/troubleshooting?view=azps-15.4.0#az-and-azurerm-coexistence.

Azure CLI: The Command-Line Interface Approach

Now that you are in this section, you might be thinking that Azure CLI is cross-platform.

Yes, you are right, it is a cross-platform command-line tool that focuses on simplicity and ease of use, providing a consistent experience across different operating systems.

Azure CLI commands are designed to be concise and follow a logical structure.

Syntax and Scripting

The verb-noun structure, similar to PowerShell, is also used by Azure CLI commands, but with a more straightforward, concise approach.

For instance, commands like az vm list or az group create follow this convention. The scripting language is based on JSON, making it easy to understand and write scripts for Azure resource management.

Integration with Azure

Azure CLI is built to be a lightweight and efficient tool for managing Azure resources.

Not only that, it offers a wide range of commands for various Azure services, giving users the flexibility to interact with Azure resources through a familiar command-line interface.

This is the tool of choice for users who prefer a streamlined, consistent experience across platforms.

Cross-Platform Support

The main strength of Azure CLI is its native cross-platform support, which works seamlessly on Windows, Linux, and macOS, making it a perfect choice for users working in different environments.

Choosing the Right Tool

We are now in a section that lets us choose between PowerShell and Azure CLI.

Well, everything depends on users’ likes and comfort levels with both.

PowerShell will always be favored by those who are comfortable with scripting, prefer a more comprehensive, object-oriented approach, or excel in complex automation scenarios.

On the other hand, Azure CLI appeals to users seeking simplicity and consistency in their command-line interactions, and its easy-to-understand syntax makes it an excellent choice for quick tasks and those who prefer a more command-oriented approach.

Conclusion

When using Azure in a day-to-day setting, the choice between PowerShell and Azure CLI boils down to individual preferences and, of course, the complexity of the tasks at hand.

If the user is comfortable with Windows environments, PowerShell’s object-oriented scripting capabilities make it a perfect choice for automating and managing Azure resources.

However, if you are seeking a consistent, efficient command-line experience across operating systems, Azure CLI is your choice.

The best advice is to master both tools so users can navigate the Azure ecosystem with greater flexibility and efficiency.

Until next time, happy programming, and good luck with your career!

Why I Built a $0.99 VPS and What I Learned

I got tired of paying $5-10/mo for a VPS I only needed to run a bot or test a script. So I built NATBox — NAT-based Linux containers starting at $0.99/mo. Here’s what I actually learned building it.
NAT vs dedicated IP — most people don’t need a dedicated IP
The whole idea behind NAT VPS is simple: instead of giving each container its own public IP (expensive), you share one host IP and assign each container a dedicated range of ports. 20 ports per container, forwarded via iptables from the host. Port 1 of your range is SSH. Ports 2-20 are yours — run web servers, bots, proxies, whatever.
For most lightweight workloads, this is completely fine. You only need a dedicated IP if you’re running something that requires it — like a mail server. For everything else, 20 ports is plenty.
The provisioning stack is a bash script
No Laravel. No Node. No fancy dashboard. The entire backend — create, suspend, renew, delete, expiry checks, email notifications, iptables rules — is a single bash script managing LXC containers and a pipe-delimited flat file as a database.
It’s not pretty, but it’s reliable, fast, and I understand every line of it. When something breaks at 2am, I can fix it in under 5 minutes.
The weirdest bugs I hit
Building this taught me more about Linux internals than anything else I’ve done. A few memorable ones:

pam_loginuid.so

was silently killing SSH sessions inside LXC because /

proc/self/loginuid

isn’t writable in containers. The fix: comment it out in

/etc/pam.d/sshd

before the container ever boots.
dnf on Fedora was getting OOM-killed inside 256MB containers just resolving package metadata. Fix: pre-create a 512MB swapfile on the host into the container’s rootfs before starting it.
OpenSSH 9.2 on Debian 12 calls

linux_audit_write_entry()

during session open, which requires

CAP_AUDIT_WRITE

. I dropped that cap in an earlier version and SSH started dying immediately after auth. One of those bugs that takes an hour to find and 3 seconds to fix.

What $0.99/mo actually gets you
256MB RAM, 3GB SSD, 20 dedicated ports, full root access, your choice of 6 distros. It’s a real Linux container, not a toy. I run bots on nano plans. People use them for proxies, learning environments, lightweight APIs, scrapers, testing.
If you want to try it: natbox.io

From “Under Promise, Over Deliver” to thinking in systems

Dakshim

This is a submission for the 2026 WeCoded Challenge: Echoes of Experience

Back in 2019, Being an avid reader, I used to brag about having 120+ books in my mini library.

Dakshim's mini library

Yes a mini library, a quit, sacred space for an introvert me, It was my den; the place where I used to declutter my mind, recharge, and think.

Years of Chaos

Then came the year 2020, during Covid Lockdown, chaos happened, and there was uncertainty all around.

Amid fears of layoffs and ambiguity of working from home, collaborating remotely, I was promoted to lead a team of 4 entry-level developers.

I threw myself into it, by upskilling the team in modern web technologies and remote collaboration tools.

Word got out, People from other teams who wanted to learn JavaScript also joined my daily after-office classes.

But then, reality bit. My organization underwent a massive restructuring, and a team of 40 got downsized to 22; I survived, but that survival came with a hidden tax.

Cost of Survival

The survival comes with a cost, a cost where one event led to another,
where imposter syndrome entered, along with people pleasing.

As the only person left with full-stack experience in React and Laravel, my plate didn’t just fill up; it overflowed. I was suddenly tasked with assisting Business Development to convert leads.

As a first-timer, I followed what everyone says, the standard industry mantra:

Under-promise and over-deliver.

What they don’t tell you is that this advice comes with a “terms and conditions” clause.

When you over-deliver repeatedly to the same person, you aren’t being a hero; you’re setting a dangerous new baseline. You become taken for granted.

In my case, over-delivering led to massive scope creep. The team I was supposed to lead started feeling the heat.

I lost the mental space required to lead effectively.

The Breaking Point

The true cost of this “over-delivery” didn’t just impact my code; it taxed my family.

In August 2023, my father suffered his first brain stroke. It was a terrifying, grounding moment that laid bare the fragility of my “hustle.”

While I was busy over-delivering to clients who took me for granted, I was under-delivering to the people who actually mattered.

I had lost the mental and emotional space required to be a lead at work, but more importantly, to be a son at home.

The weight of being constantly “on” meant I was physically present but mentally absent when my family needed me most.

Reclaiming the Bookshelf

Today, I’m in the process of reclaiming my time and my mental “den.” It’s a slow build back to that 2019 peace of mind, and I’m doing it through a new set of operating principles:

  1. Setting Hard Boundaries: My time is no longer an open-ended resource.

  2. The Art of ‘No’: Learning that saying “no” to a feature is often saying “yes” to quality and family.

  3. Promising Rightly: Moving away from the “over-deliver” trap and toward accurate, sustainable expectations.

  4. Killing the Hype: Eliminating buzzwords and stopping the chase for every new shiny framework.

  5. Rigid Scoping: Evaluating project boundaries with a critical eye before the first line of code is written.

  6. Fundamentals First: Building strategies and systems that are reliable, not just fast.

My mini-library is still there. Now, I’m finally making sure I have the headspace to actually read the books in it and the time to be there for the people who hold my world together.

Docker for Beginners: Everything You Need to Know

Starting your journey into the world of Docker can feel like standing at the edge of a vast ocean, filled with mysterious containers and images. But fear not: Docker is one of the most approachable tools you can add to your developer toolkit, vastly simplifying application deployment and scaling. In this guide, we’ll demystify Docker for you with a straightforward and practical approach.

What is Docker and Why Should You Care?

At its core, Docker is a tool designed to make it easier to create, deploy, and run applications by using containers. Containers are lightweight, portable, and ensure that software runs reliably when moved from one environment to another. But why should you care about Docker? Because it allows you to ship applications faster, reduces “it works on my machine” problems, and scales effortlessly.

Takeaway: Imagine being able to set up an entire development environment with a single command that works consistently whether on your local machine, staging, or production server. That’s the magic of Docker.

Getting Started: Installing Docker

Before you begin, you need to install Docker on your machine. The good news is that Docker provides straightforward installation guides for different operating systems, including Windows, macOS, and Linux.

For macOS and Windows, the Docker Desktop application is the easiest to install. On the other hand, if you’re using a Linux distribution, you might want to use your package manager. For instance, on Ubuntu:

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

Once installed, verify the installation with:

docker --version

Actionable Step: Install Docker and run the version command to ensure it’s properly set up on your machine.

Basic Concepts: Images and Containers

Understanding Docker’s key concepts is crucial for any beginner. Docker uses two main ingredients: images and containers. Think of an image as a snapshot of your application, complete with all the dependencies. A container is an instance of an image, a running application, if you will.

For example, you can pull an existing image of Node.js from the Docker hub and run it as a container:

docker pull node
docker run -it --rm node

This command will pull the Node.js image and start a container instance where you can begin using Node immediately.

Takeaway: Practicing pulling images and running containers will get you comfortable with initial Docker commands.

Building Your First Docker Container

Creating your own Docker image is a simple process that starts with writing a Dockerfile. A Dockerfile contains instructions on how your image should be built. Let’s create a basic one:

Create a file named Dockerfile:

FROM node:alpine
COPY . /app
WORKDIR /app
RUN npm install
CMD ["node", "app.js"]

This simple Dockerfile tells Docker to use a base image of Node with Alpine Linux, copy your code to an /app directory, set it as the working directory, run npm install, and finally execute app.js when the container starts.

Build the image with:

docker build -t my-node-app .

And run it with:

docker run -d my-node-app

Practical Step: Write a simple Dockerfile, build your image, and run a container using your own application code.

Docker Compose: Orchestrating Multi-Container Environments

Docker Compose is a tool that allows you to define and manage multi-container Docker applications. With a simple YAML configuration file, you can define all services so they can be spun up in a single command.

Here’s an example docker-compose.yml file for a simple web application with a database:

version: '3.8'
services:
  web:
    image: my-node-app
    ports:
      - "3000:3000"
  db:
    image: mongo
    ports:
      - "27017:27017"

Then, bring everything up with a single command:

docker-compose up

Implementing it: Use Docker Compose to simplify the management of complex applications, reducing the time to spin up or tear down environments.

Your Next Steps in the World of Docker

Now that you have a working understanding of Docker, it’s time to experiment and integrate it into your projects. Start by containerizing a small application, using Docker Compose, and exploring the Docker Hub for a variety of pre-built images tailored to your needs.

Let’s move from theory to practice. Leave a comment on what application you plan to containerize first. Follow our blog for more in-depth tutorials, and don’t hesitate to reach out with questions as you advance in your Docker journey.

Happy Dockerizing!

The LiteLLM Supply Chain Attack Just Changed Everything – Here’s How to Protect Your AI Stack

TL;DR

On March 24, 2026, the widely-used LiteLLM Python package was compromised in a supply chain attack. Malicious versions stole credentials from tens of thousands of developers. This post breaks down what happened, why AI tooling is uniquely vulnerable, and how MCP-based security tools like kira-lite-mcp can catch these threats before they hit production.

What Happened: The LiteLLM Compromise

Two days ago, the AI development community got a brutal wake-up call.

LiteLLM – the open-source Python library that provides a unified interface across 100+ LLMs (OpenAI, Anthropic, VertexAI, and more) – was hit by a supply chain attack. Versions 1.82.7 and 1.82.8, published directly to PyPI, contained a multi-stage credential stealer targeting SSH keys, cloud provider tokens, Kubernetes secrets, cryptocurrency wallets, and .env files.

The attack was carried out by TeamPCP, a threat group that had been chaining compromises across the open-source ecosystem since late 2025. They gained access to a LiteLLM maintainer’s PyPI credentials through a prior compromise of Aqua Security’s Trivy scanner – meaning one breach cascaded into another.

Here’s a timeline of what went down:

  • ~08:30 UTC – Malicious versions 1.82.7 and 1.82.8 published to PyPI
  • 10:39-16:00 UTC – Compromised packages available for download
  • ~11:25 UTC – PyPI quarantined the packages
  • By end of day – Versions yanked, credentials rotated, external IR engaged

The payload was embedded in proxy_server.py and, in version 1.82.8, a .pth file (litellm_init.pth) that executed automatically on every Python process startup. Not when you ran your app. Not when you imported litellm. When Python itself started.

If you ran pip install litellm without a pinned version during that window, the malware was already running before your code did.

Why This Matters More Than a Typical Supply Chain Attack

LiteLLM isn’t just another package. It sits at the center of the AI stack, acting as a gateway between applications and multiple LLM providers. That means it often has access to:

  • API keys for OpenAI, Anthropic, Azure, GCP, and other providers
  • Environment variables with database passwords and service credentials
  • Kubernetes cluster secrets when deployed as a proxy
  • CI/CD pipeline tokens when used in automated workflows

According to Wiz, LiteLLM is present in 36% of all cloud environments. That’s an astonishing blast radius for a three-hour window of exposure.

The stolen data was encrypted and exfiltrated to a C2 domain designed to look legitimate: models.litellm[.]cloud. The payload also attempted to deploy privileged Kubernetes pods to every node in a cluster, giving the attackers persistent access even after the initial malware was removed.

This wasn’t an isolated incident. TeamPCP’s campaign has now spanned five ecosystems – GitHub Actions, Docker Hub, npm, Open VSX, and PyPI – with each compromise yielding credentials that unlock the next target.

The Real Problem: We Don’t Scan What We Should

Here’s the uncomfortable truth: most development teams have zero automated security checks between their AI coding assistant generating code and that code hitting production.

Think about the typical AI-assisted development workflow:

  1. Developer asks an AI assistant to write a function
  2. AI generates code, often pulling in dependencies
  3. Developer reviews it (maybe), copies it, and ships it

At no point in that flow did anyone check whether those dependencies have known vulnerabilities. Nobody verified that the packages the AI suggested actually exist (package hallucination is a real attack vector). And nobody scanned the generated code itself for injection vulnerabilities, hardcoded secrets, or insecure patterns.

The LiteLLM incident makes this painfully clear. If your project had an unpinned litellm dependency and your CI pipeline ran pip install during that three-hour window, you were compromised. Period.

Enter kira-lite-mcp: Security Scanning Inside the AI Loop

This is where tools like @offgridsec/kira-lite-mcp become critical.

kira-lite-mcp is an MCP (Model Context Protocol) server that brings real-time security scanning directly into your AI coding workflow. Instead of scanning after code is written and committed, it scans code as it’s being generated – inside the same conversation where your AI assistant is writing it.

What Makes It Different

It runs entirely on your machine. Kira-lite ships with Kira-Core, a compiled Go binary bundled for macOS, Linux, and Windows. Your code never leaves your laptop. For proprietary codebases or regulated industries, this is non-negotiable.

376 security rules out of the box. It covers OWASP Top 10:2025, OWASP API Security Top 10, and – critically – OWASP LLM Top 10:2025. That last category catches vulnerabilities that traditional scanners completely miss: LLM output passed directly to eval() or exec(), prompt injection patterns, and user input concatenated into prompt templates.

Five MCP tools that your AI assistant can invoke contextually:

  • scan_code – scans a snippet before it’s written to disk. The AI literally checks its own work.
  • scan_file – scans an existing file and auto-triggers dependency scanning on lockfiles.
  • scan_diff – compares original vs. modified code and flags only new vulnerabilities.
  • scan_dependency – checks your lockfiles against CVE databases across 13 formats (npm, PyPI, Go, Maven, crates.io, RubyGems, and more).
  • scan_project – full project-level scan with configurable severity thresholds.

How This Would Have Helped With LiteLLM

Let’s map kira-lite’s capabilities directly to the LiteLLM attack scenario:

Dependency scanning catches known-bad versions. If your project’s requirements.txt or lockfile pulled in litellm 1.82.7 or 1.82.8 after the CVE was published, kira-lite’s dependency scanner would flag it. It checks against live CVE databases, not just a static list – so as advisories are published, your scans pick them up.

Code scanning catches suspicious patterns. The LiteLLM payload used os.system() calls, base64-encoded strings, and network exfiltration patterns. These are exactly the kinds of patterns that SAST rules detect. If an AI assistant generated code that imports from or interacts with a compromised dependency in a suspicious way, scan_code would catch it before it’s saved.

Project-level scanning catches transitive risks. Many developers weren’t even using LiteLLM directly – it was pulled in as a transitive dependency through AI agent frameworks, MCP servers, or LLM orchestration tools. A full project scan would surface these hidden dependencies.

Setting It Up (It Takes 30 Seconds)

npx @offgridsec/kira-lite-mcp

That’s it. Zero config. Works with Claude Code, Claude Desktop, Cursor, VS Code, and any MCP-compatible client.

For Claude Desktop, add to your config:

{
  "mcpServers": {
    "kira-lite": {
      "command": "npx",
      "args": ["-y", "@offgridsec/kira-lite-mcp"]
    }
  }
}

Once configured, your AI assistant can call the security scanning tools automatically – catching vulnerabilities in the same conversation where code is being written.

Broader Lessons From the LiteLLM Incident

The LiteLLM compromise isn’t the end of this campaign. As Endor Labs put it: “TeamPCP has demonstrated a consistent pattern: each compromised environment yields credentials that unlock the next target.”

Here’s what every team building with AI should take away:

1. Pin Your Dependencies

If your requirements.txt says litellm instead of litellm==1.82.6, you were exposed. Pin versions. Use lockfiles. Verify hashes. This is basic supply chain hygiene that too many AI projects skip because they move fast.

2. Treat AI-Generated Code Like Untrusted Code

Your AI assistant doesn’t know about zero-day supply chain attacks. It doesn’t verify that the packages it suggests are uncompromised. Treat its output the same way you’d treat code from a contractor who doesn’t know your security policies.

3. Scan In the Loop, Not After the Fact

The traditional model – write code, commit, CI scans, fix later – doesn’t scale when AI is generating code at 10x the speed of manual development. Tools like kira-lite-mcp shift security scanning left of left: into the generation step itself.

4. Watch Your Transitive Dependencies

LiteLLM wasn’t just installed directly. It was pulled in by MCP servers, agent frameworks, and orchestration tools. Know your dependency tree. Audit it regularly. Use scan_dependency on every lockfile in your project.

5. Assume Breach, Rotate Fast

If you installed litellm 1.82.7 or 1.82.8, assume every credential on that machine is compromised. SSH keys, cloud tokens, API keys, database passwords, crypto wallets – rotate all of them. Check for persistence mechanisms (~/.config/sysmon/sysmon.py, ~/.config/systemd/user/sysmon.service). In Kubernetes environments, audit kube-system for pods matching node-setup-*.

The Bottom Line

The AI supply chain is now a high-value target. LiteLLM’s 3.4 million daily downloads made it an incredibly efficient attack vector – and the attackers knew it.

We can’t prevent every supply chain compromise. But we can build security into the development workflow itself, catching vulnerabilities before they become incidents. Tools like kira-lite-mcp represent a fundamental shift: from security as a gate at the end of the pipeline to security as a collaborator sitting next to you while you code.

The LiteLLM incident cost organizations time, trust, and potentially sensitive data. The next supply chain attack – and there will be a next one – doesn’t have to.

If you found this useful, give it a like and follow for more on AI security and developer tooling. Have questions about securing your AI development workflow? Drop them in the comments.

Tags: #security #ai #programming #devops

FAQ: LiteLLM Supply Chain Attack and AI Security

What was the LiteLLM supply chain attack?
On March 24, 2026, the LiteLLM Python package on PyPI was compromised by a threat group called TeamPCP. Versions 1.82.7 and 1.82.8 contained credential-stealing malware that targeted SSH keys, cloud tokens, API keys, and Kubernetes secrets. The malicious packages were available for approximately three hours before being removed.

How do I know if I was affected by the LiteLLM attack?
You were likely affected if you ran pip install litellm without a pinned version between 10:39 UTC and 16:00 UTC on March 24, 2026, and received version 1.82.7 or 1.82.8. Run pip show litellm to check your installed version. Users of the official LiteLLM Proxy Docker image were not impacted.

What is kira-lite-mcp?
kira-lite-mcp (@offgridsec/kira-lite-mcp) is an MCP-based security scanner that integrates directly into AI coding assistants like Claude Code, Cursor, and VS Code. It scans code for vulnerabilities, checks dependencies against CVE databases, and detects insecure patterns – all in real-time during the development process, entirely on your local machine.

How does MCP help with security scanning?
MCP (Model Context Protocol) allows AI assistants to invoke external tools contextually. With an MCP security scanner, the AI can proactively check its own generated code for vulnerabilities, scan dependencies for known CVEs, and flag insecure patterns before the code is ever saved to disk.

What should I do if I installed a compromised version of LiteLLM?
Immediately remove the package, purge your pip cache, rotate all credentials that were accessible on the affected machine (SSH keys, cloud tokens, API keys, database passwords), check for persistence mechanisms, and if running in Kubernetes, audit for unauthorized pods. Consider rebuilding affected systems from a known clean state.

Your DEV Credentials Shouldn’t Be Able to Sink PROD

AWS SDLC Account Structure

Most engineering teams think environment isolation means having a “dev” and “prod” flag somewhere in their deployment pipeline.

They’re wrong.

That approach doesn’t isolate anything, it just moves the risk around.

The AWS SDLC Account Pattern with Full Environment Segregation is what serious cloud architecture actually looks like. It’s not just a best practice. It’s the difference between teams that accidentally push breaking changes to production at 2am and teams that catch those changes before they ever leave a development branch. It’s the difference between a breach in your DEV environment that gets contained, blast radius controlled, damage limited – and a breach in DEV that silently walks into PROD, taking customer data with it and sinking the whole ship.

Here’s how it works

AWS SDLC Account Structure

And here’s why every growing engineering team should be building this way.

The Problem With Shared AWS Accounts

If your DEV, STAGING, and PROD workloads live in the same AWS account, you have a blast radius problem.

A misconfigured IAM policy in your development environment can expose production resources. A runaway Lambda function eating through concurrency limits in staging can throttle your live API. A developer testing aggressive auto-scaling rules in dev can hit account-level service quotas that affect production before anyone notices.

But the operational blast radius is only half the story.

The security blast radius from a compromised shared account is where things get truly dangerous.

If an attacker compromises a single access key, an OIDC token, or a CI/CD pipeline role in a shared account, they could inherit access to every environment in that account, including production data, production databases, and production secrets. There is no hard boundary between environments within the same account. One breach, total exposure.

The insider threat picture is equally concerning. In a shared account, an engineer with dev access and enough determination can reach production. You can write IAM policies that try to prevent it, but IAM within a single account is advisory, it can be overly permissive, it can be misconfigured, and it can be escalated.

Account-level segregation makes the boundary physical. A developer credential scoped to DEV simply cannot access PROD without an explicit cross-account IAM role grant, a deliberate architectural decision, not a policy footnote that someone forgot to update.

Shared accounts feel cheaper and simpler at first. But as your team grows and your infrastructure complexity increases, shared accounts become a liability. You can’t enforce strict RBAC by environment. You can’t audit cost by environment cleanly. And you certainly can’t guarantee that a bad deployment in your lowest environment won’t propagate upward.

The solution isn’t better tagging or smarter guardrails within a shared account. The solution is account-level segregation, and AWS Organisations makes it operationally feasible to implement properly.

Three Accounts, One Purpose Each

The pattern separates workloads into three completely isolated AWS accounts, each serving a single purpose.

The DEV Account is for feature development and integration testing. Engineers deploy more freely here. Branches get their own environments when needed. Nothing here is permanent, nothing here is customer-facing, all private zero-trust. The URL pattern (dev.domain.com) makes it clear that this is development. The cost here is intentionally low, and it should report as such.

The STAGE Account is a production replica. Same resources, same network architecture, same IAM setup as production, just without real customer traffic. This is where UAT happens, where load tests run, and where you validate that what you’re about to ship actually behaves like you think it will. Stage sits at stage.domain.com, nothing here is customer-facing, all private zero-trust and it should scare you slightly to deploy there. That fear is useful.

The PROD Account is the one that matters. Blue/green deployments running at domain.com, with traffic switching done deliberately and with rollback paths tested in stage first. Nothing reaches PROD that hasn’t passed STAGE. That’s not a policy suggestion it’s enforced by the pipeline architecture itself.

Account-level segregation means there’s no accidental blast radius between these environments. A misconfigured resource in DEV literally cannot affect PROD, they’re in different AWS accounts with separate IAM boundaries, separate VPCs, and separate billing.

One Infrastructure Repo, Three Environments

Infrastructure-as-code is non-negotiable at this scale, and the pattern uses a mono-infra approach with Terragrunt to manage it cleanly.

A single Git repository contains the infrastructure for all three accounts, organised into three folder paths: /prod, /stage, and /dev. Each folder contains the Terragrunt configuration for that account, same modules, different variable files and remote state backends.

Why Terragrunt? Because raw Terraform at this scale leads to copy-paste hell. When you need to update your VPC module, you want to change it once and have it propagate through environments in a controlled way, not hunt down three duplicated configurations and hope you got them all. Terragrunt’s include blocks and dependency management let you DRY up your infrastructure code the same way you’d DRY up application code.

The mono-repo structure also enforces a natural promotion pattern: infrastructure changes move from dev to stage to prod via pull requests, with review and approval gates at each stage. No environment drifts silently. Every difference between PROD and DEV is intentional and reviewable.

Two PR Workflows, One Direction

The pattern distinguishes between two distinct flows, and most teams conflate them at their peril.

The infra workflow governs changes to the infrastructure itself, the Terraform modules, the VPC configs, the account baselines. These PRs are raised against the infrastructure mono-repo and require review from the people who understand the blast radius of an IAM policy change or a security group modification. Infrastructure changes flow from DEV account config to STAGE account config to PROD account config.

The app workflow governs application deployments, the code that runs inside the infrastructure. A feature branch gets deployed to DEV, a PR merge to main triggers a STAGE deployment and automated test suite, and a deliberate promotion step moves the release to PROD. These workflows live in the application repos, not the infra repo, but they reference the same account structure and environment naming conventions.

Keeping these workflows separate matters because they operate at different risk levels and different cadences. Application code might deploy to production daily, infrastructure changes might go through a full change management process. Mixing them creates confusion about who reviews what and what the rollback options are.

Shared Services, The Fourth Account You Don’t Often See

Here’s where many AWS multi-account architectures fall short, they model the application environments well but neglect the shared services problem.

Every environment needs to pull container images. Every environment needs identity and access management for users. If you solve that independently per account, you end up with three separate ECR registries, three separate Cognito user pools, and a synchronisation problem that compounds over time.

The SDLC Account Pattern includes a Shared Services Account that sits alongside the three environment accounts.

ECR and Cognito are good examples:

ECR: All three environments pull from the same registry. A single image build, tested and tagged, gets promoted across environments without rebuilding. The image you ran your integration tests on in STAGE is exactly the image that ships to PROD, no surprises, no environment-specific build drift.

Cognito: Rather than duplicating user pools per environment, Shared Services hosts the identity plane. Environment-specific configurations reference the shared pool, with appropriate user segmentation between DEV, UAT, and PROD user groups.

The Shared Services Account uses Zero Trust connectivity to each environment account. explicit, least-privilege cross-account IAM roles with no implicit trust assumptions. A resource in PROD pulling an image from ECR gets exactly the permissions needed for that operation, scoped to that resource, with no ambient access to anything else in the Shared Services Account.

The Provider to Consumer Model

The governance philosophy underpinning this entire pattern is closer to a shared responsibility model than a simple owner/user split and it’s worth being precise about what each side is responsible for.

The Provider is the platform team. They own the AWS Landing Zone, manage core transit networking, and maintain the version-controlled VPC configuration that defines how accounts connect and communicate. They vend new AWS accounts on request, applying a security baseline before a single line of application code is ever written, SCPs, GuardDuty, centralised logging, IAM boundaries, the works. When a new team needs an environment, the Provider provisions the account, attaches the consumer’s mono repo, and hands over something that’s ready to build in.

The Consumer is the workload build team. They inherit a fully baselined, network-connected AWS account with a repo already wired up. Their job is to build product inside the guardrails the Provider has established, not to manage networking, not to configure security tooling, not to think about account structures. That separation is the whole point.

This clean division is what makes the pattern scale. The platform team can iterate on the landing zone and security baseline without touching application code. Application teams can ship fast without becoming AWS infrastructure experts. And when something goes wrong in a consumer account, the Provider’s controls mean the blast radius stops at the account boundary.

Why This Matters Now

Multi-account AWS architecture used to be something only large enterprises could afford to implement. AWS Organisations made it accessible to teams of any size. Terragrunt has made it maintainable. And several high-profile production incidents, caused by developers with too much access in too few accounts, have made the case for account-level segregation non negotiable.

The AWS SDLC Account Pattern isn’t about adding complexity. It’s about moving complexity from the runtime, where a mistake becomes an incident, to the design phase, where a mistake becomes a PR comment.

Build environments that are safe to experiment in. Build a staging environment that earns its name. Build production isolation that actually works.

The principles this pattern encodes are the right ones. Your implementation will vary, but if you’re not operating with account-level segregation today, start planning the migration.

The 2am call, the compromised key, the rogue credential, they all get a lot quieter with a bulkhead between them and production.