How to Scrape LinkedIn Profiles and Jobs Without Getting Banned (2026)

Advanced 35 minutes

Prerequisites

  • Python 3.10+ installed
  • Working knowledge of HTTP requests, headers, and status codes
  • Familiarity with browser DevTools and network inspection
  • A reading of LinkedIn's User Agreement, and sign-off from your legal counsel on the data you intend to collect
  • Residential or real-device egress for production runs

A request to a LinkedIn profile URL without a browser User-Agent comes back as HTTP 999, a nonstandard status LinkedIn uses to refuse traffic it does not recognise. That is the first wall most LinkedIn scrapers hit, and it is the mildest one.

This guide covers one surface: LinkedIn's public, logged-out pages. Profile pages under /in/, the guest job search endpoint, and the guest job detail endpoint all return data to a request carrying no session cookie and no account. Everything here works without logging in.

That boundary is deliberate. Extracting session cookies from a browser, automating a logged-in account, or spreading activity across a pool of accounts to stay under a limit are all breaches of LinkedIn's User Agreement, and a court has held those terms enforceable. This guide does not cover them.

LinkedIn's newsroom reports more than 1.3 billion members (checked August 2026). The profiles set to public are readable without an account, and so are job listings. What follows is that surface: what it exposes, how to parse it, and how to pace requests so you keep getting answers.

  1. Draw the Legal Line Before You Write Any Code

    LinkedIn scraping has a case history, and it does not say what most summaries say it says.

    In hiQ Labs v. LinkedIn (9th Cir., 18 April 2022), the Ninth Circuit held that scraping data from publicly accessible web pages is unlikely to be access "without authorization" under the Computer Fraud and Abuse Act. That is the ruling everyone cites, and within its scope it stands.

    It is not the end of the case. In November 2022 the Northern District of California held that a website user agreement's provisions prohibiting data scraping and the creation of fake profiles are enforceable through a breach-of-contract claim. The parties settled on 6 December 2022, with a $500,000 judgment entered against hiQ and injunctive relief barring it from scraping LinkedIn.

    So the two questions came apart. On the CFAA and public data, hiQ won. On contract liability under the User Agreement, hiQ lost, paid, and stopped. A guide that cites hiQ without the second half is quoting the good news and dropping the bill.

    Two consequences for how you build. Public and logged-out is the defensible surface: it is the only one the CFAA holding reaches. And contract exposure does not vanish because you stayed logged out, because LinkedIn's User Agreement still prohibits scraping. That second one is a business and legal decision, not an engineering one.

    Personal data is a separate track. Profile records are personal data under the GDPR and comparable regimes whether or not the profile is public, so lawful basis, retention limits, and subject-access handling apply to anything you store.

    Tip: Ask the legal question before the engineering question. Finding out you cannot use the data costs far less before you have built the pipeline than after.

  2. Map What LinkedIn Returns When You Are Logged Out

    Three endpoints answer without a session cookie. Each was checked in August 2026.

    Public profile page. The URL linkedin.com/in/{public-id} returns the profile HTML carrying a single JSON-LD block, the one tagged application/ld+json. Inside it is a @graph holding a Person node with name, jobTitle, description, address, worksFor, alumniOf, knowsLanguage, and sameAs. This is the structured data LinkedIn publishes for search engines, so it is stable in a way the surrounding page markup is not.

    Guest job search. The endpoint linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search takes keywords, location, and start. It returns an HTML fragment of list items, one div.base-card.job-search-card per listing, each carrying a data-entity-urn attribute of the form urn:li:jobPosting:{id}.

    Guest job detail. The endpoint linkedin.com/jobs-guest/jobs/api/jobPosting/{jobId} returns the full posting: description text under .show-more-less-html__markup, and seniority level, employment type, and job function under .description__job-criteria-item.

    What you do not get without logging in: work-history detail beyond what the JSON-LD carries, skills, endorsements, recommendations, contact details, connection graphs, and people search. If your use case requires those, the answer is LinkedIn's partner APIs or a licensed data provider, not a workaround.

    Tip: The JSON-LD block is the highest-value target on a profile page. It is machine-readable, LinkedIn maintains it deliberately for search indexing, and it survives front-end redesigns that break CSS-selector parsers.

  3. Send Requests a Browser Would Recognisably Send

    LinkedIn refuses unrecognised clients before it looks at anything else. A urllib or bare curl request to a public profile returns HTTP 999, checked August 2026. No amount of slowing down changes that answer.

    The two signals that matter are ones your HTTP client sets for you, not ones you write into your code.

    Header set and ordering. Python's requests library sends a header sequence no browser produces. The names may be right; the order is not.

    TLS fingerprint. JA3 and JA4 hash the ClientHello: cipher suites, extensions, elliptic curves, and their order. These come from the TLS library, not from your code. A Python client claiming to be Chrome presents a handshake no Chrome build has ever produced.

    The curl_cffi library addresses both by binding to a curl-impersonate build that replays a real browser's ClientHello and header order.

    None of this defeats a security control. A page served to anonymous browsers is meant to be served to anonymous clients, and matching the handshake is how you get treated as the ordinary reader you are.

    python
    from curl_cffi import requests
    
    BROWSER = "chrome124"
    
    
    def make_session(proxy=None):
        """A session whose TLS handshake and header order match a real Chrome build."""
        session = requests.Session(impersonate=BROWSER)
        session.headers.update({
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "en-US,en;q=0.9",
        })
        if proxy:
            session.proxies = {"http": proxy, "https": proxy}
        return session
    
    
    def fetch_public_profile(session, public_id):
        """Fetch a logged-out profile page. Returns None if the profile is not public."""
        url = f"https://www.linkedin.com/in/{public_id}/"
        response = session.get(url, timeout=20, allow_redirects=True)
    
        if response.status_code == 999:
            raise RuntimeError("Refused at the edge: check client fingerprint and egress IP")
        if response.status_code == 429:
            raise RuntimeError("Rate limited: back off")
        if "/authwall" in response.url:
            return None  # Not public. Do not retry this from another IP.
    
        response.raise_for_status()
        return response.text

    Tip: Point your client at a TLS fingerprint echo service before you point it at LinkedIn. If the JA3/JA4 hash does not match a shipping Chrome build, fix that before tuning anything else.

  4. Parse the Public Profile JSON-LD

    Parse the structured data, not the page. The @graph in the profile's JSON-LD block holds a Person node whose shape LinkedIn keeps stable for search engines, while the surrounding HTML class names change with every front-end release.

    The Person node nests employment and education as Organization and EducationalOrganization entries, each with a member object of type OrganizationRole carrying startDate and endDate. Those dates are years, not full dates, on the public surface.

    Treat every field as optional. A profile with no listed employer, no location, or no education is normal, and a parser that raises on a missing key will spend more time failing than collecting.

    python
    import json
    from dataclasses import dataclass, field
    from bs4 import BeautifulSoup
    
    
    @dataclass
    class LinkedInProfile:
        public_id: str
        name: str = ""
        headline: str = ""
        locality: str = ""
        country: str = ""
        positions: list[dict] = field(default_factory=list)
        education: list[dict] = field(default_factory=list)
    
    
    def _person_node(html):
        """Pull the Person node out of the page's JSON-LD @graph."""
        soup = BeautifulSoup(html, "html.parser")
        for tag in soup.find_all("script", type="application/ld+json"):
            try:
                payload = json.loads(tag.string or "")
            except json.JSONDecodeError:
                continue
            for node in payload.get("@graph", []):
                if node.get("@type") == "Person":
                    return node
        return None
    
    
    def _org_entries(nodes):
        entries = []
        for node in nodes or []:
            role = node.get("member") or {}
            entries.append({
                "name": node.get("name", ""),
                "url": node.get("url", ""),
                "start": role.get("startDate"),
                "end": role.get("endDate"),
            })
        return entries
    
    
    def parse_profile(html, public_id):
        person = _person_node(html)
        if person is None:
            return None
    
        address = person.get("address") or {}
    
        return LinkedInProfile(
            public_id=public_id,
            name=person.get("name", ""),
            headline=person.get("description", ""),
            locality=address.get("addressLocality", ""),
            country=address.get("addressCountry", ""),
            positions=_org_entries(person.get("worksFor")),
            education=_org_entries(person.get("alumniOf")),
        )

    Tip: jobTitle comes back as a list when a person holds several roles and as a bare string when they hold one. Normalise it on the way in rather than discovering the inconsistency later in your warehouse.

  5. Collect Public Job Listings Through the Guest Endpoints

    LinkedIn's job surface has a genuinely public half. The guest search endpoint returns an HTML fragment of result cards to an anonymous request, and the guest detail endpoint returns the full posting for a given job ID. Neither needs a cookie.

    Search results carry the job ID, title, company, location, canonical URL, and an ISO date on a time element. Descriptions are not in the search response, so a complete record costs one detail request per listing.

    The class names below are LinkedIn's current front-end markup, checked August 2026. They are implementation detail and they will change. Structure your parser so a selector miss produces an empty field and a logged warning rather than a crash.

    python
    from dataclasses import dataclass
    from bs4 import BeautifulSoup
    
    GUEST_SEARCH = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
    GUEST_DETAIL = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/{job_id}"
    
    
    @dataclass
    class JobListing:
        job_id: str
        title: str = ""
        company: str = ""
        location: str = ""
        posted_at: str = ""
        url: str = ""
        description: str = ""
        employment_type: str = ""
        seniority_level: str = ""
    
    
    def _text(node):
        return node.get_text(strip=True) if node else ""
    
    
    def search_jobs(session, keywords, location="", start=0):
        """One page of the logged-out job search."""
        response = session.get(
            GUEST_SEARCH,
            params={"keywords": keywords, "location": location, "start": start},
            timeout=20,
        )
        response.raise_for_status()
    
        soup = BeautifulSoup(response.text, "html.parser")
        listings = []
    
        for card in soup.select("div.job-search-card"):
            time_tag = card.select_one("time")
            link = card.select_one("a.base-card__full-link")
            listings.append(JobListing(
                job_id=card.get("data-entity-urn", "").rsplit(":", 1)[-1],
                title=_text(card.select_one("h3.base-search-card__title")),
                company=_text(card.select_one("h4.base-search-card__subtitle")),
                location=_text(card.select_one("span.job-search-card__location")),
                posted_at=time_tag.get("datetime", "") if time_tag else "",
                url=link["href"].split("?")[0] if link else "",
            ))
        return listings
    
    
    def get_job_details(session, job_id):
        """Full public posting: description text plus the criteria block."""
        response = session.get(GUEST_DETAIL.format(job_id=job_id), timeout=20)
        response.raise_for_status()
    
        soup = BeautifulSoup(response.text, "html.parser")
    
        criteria = {}
        for item in soup.select("li.description__job-criteria-item"):
            label = _text(item.select_one("h3.description__job-criteria-subheader"))
            if label:
                key = label.lower().replace(" ", "_")
                criteria[key] = _text(item.select_one("span.description__job-criteria-text"))
    
        return {
            "description": _text(soup.select_one("div.show-more-less-html__markup")),
            "seniority_level": criteria.get("seniority_level", ""),
            "employment_type": criteria.get("employment_type", ""),
            "job_function": criteria.get("job_function", ""),
            "industries": criteria.get("industries", ""),
        }

    Tip: The time element carries a machine-readable datetime attribute alongside the human string. Read the attribute. Parsing "15 minutes ago" back into a timestamp is a bug generator, and it is locale-dependent.

  6. Pace Against the Signals LinkedIn Actually Returns

    LinkedIn does not publish rate limits for its public endpoints. Any specific figure you find quoted for them is someone's guess, usually copied from another guide that also guessed. Build the pacing loop so it does not need one.

    The signals you do get are concrete. A 429 means you are going too fast, so back off. A 999 means the request was refused at the edge, on client fingerprint or IP reputation rather than volume, and slowing down will not help. A 403, or a redirect to /authwall, means the content behind that URL is not public.

    Drive concurrency from those responses instead of from a threshold. Start at one request at a time, add randomised delay, and halve the rate on the first 429 rather than after a count you invented. Recover slowly.

    Two rules are not adaptive. Respect robots.txt: read it, and do not fetch what it disallows. And never retry into an authwall. That redirect is LinkedIn telling you the resource is not public, and retrying it from a different IP is the point at which a public-data collector turns into something else.

    python
    import random
    import time
    from dataclasses import dataclass
    
    
    @dataclass
    class Pacer:
        """Request pacing driven by response codes, not by a guessed limit."""
    
        delay: float = 2.0
        min_delay: float = 1.5
        max_delay: float = 120.0
        jitter: float = 0.4
    
        def wait(self):
            time.sleep(self.delay * random.uniform(1 - self.jitter, 1 + self.jitter))
    
        def on_success(self):
            # Recover slowly. It took a backoff to get here.
            self.delay = max(self.min_delay, self.delay * 0.9)
    
        def on_throttle(self):
            # 429: halve the rate immediately.
            self.delay = min(self.max_delay, self.delay * 2)
    
        def on_refusal(self):
            # 999 is not a rate problem, so do not treat it as one.
            raise RuntimeError(
                "Refused at the edge. Check TLS fingerprint and egress before retrying."
            )
    
    
    def fetch(session, url, pacer, attempts=5):
        for _ in range(attempts):
            pacer.wait()
            response = session.get(url, timeout=20, allow_redirects=True)
    
            if response.status_code == 999:
                pacer.on_refusal()
            if response.status_code == 429:
                pacer.on_throttle()
                continue
            if "/authwall" in response.url:
                return None  # Not public. Never retry this from another IP.
    
            response.raise_for_status()
            pacer.on_success()
            return response
    
        raise RuntimeError(f"Gave up after {attempts} attempts: {url}")

    Tip: Log the status-code distribution per run. A rising share of 999 means your egress IPs or your client fingerprint are the problem; a rising share of 429 means your rate is. They need different fixes, and conflating them wastes weeks.

  7. Structure and Store What You Collect

    LinkedIn data is relational and it moves. Profiles connect to companies, companies connect to jobs, and every record is a snapshot of a moment. Key on the identifier plus the collection date so you keep a history instead of overwriting one.

    Keep the raw parsed payload alongside the extracted columns. When a selector breaks, or a field turns out to mean something other than you assumed, the raw JSON is the only thing that lets you re-derive without re-fetching.

    Retention belongs in the schema, not in an afterthought. Profile rows are personal data, so decide the retention window before the first insert and enforce it with a scheduled job rather than a policy document.

    python
    import json
    import sqlite3
    from dataclasses import asdict
    from datetime import date, timedelta
    
    SCHEMA = """
    CREATE TABLE IF NOT EXISTS profiles (
        public_id   TEXT NOT NULL,
        scraped_at  DATE NOT NULL,
        name        TEXT,
        headline    TEXT,
        locality    TEXT,
        country     TEXT,
        raw_json    TEXT,
        PRIMARY KEY (public_id, scraped_at)
    );
    CREATE TABLE IF NOT EXISTS jobs (
        job_id           TEXT NOT NULL,
        scraped_at       DATE NOT NULL,
        title            TEXT,
        company          TEXT,
        location         TEXT,
        posted_at        TEXT,
        url              TEXT,
        description      TEXT,
        employment_type  TEXT,
        seniority_level  TEXT,
        raw_json         TEXT,
        PRIMARY KEY (job_id, scraped_at)
    );
    CREATE INDEX IF NOT EXISTS idx_profiles_scraped ON profiles(scraped_at);
    CREATE INDEX IF NOT EXISTS idx_jobs_company ON jobs(company);
    """
    
    
    def init_database(db_path):
        conn = sqlite3.connect(db_path)
        conn.executescript(SCHEMA)
        return conn
    
    
    def save_profile(conn, profile):
        conn.execute(
            "INSERT OR REPLACE INTO profiles VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                profile.public_id,
                date.today().isoformat(),
                profile.name,
                profile.headline,
                profile.locality,
                profile.country,
                json.dumps(asdict(profile)),
            ),
        )
        conn.commit()
    
    
    def save_job(conn, job):
        conn.execute(
            "INSERT OR REPLACE INTO jobs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                job.job_id,
                date.today().isoformat(),
                job.title,
                job.company,
                job.location,
                job.posted_at,
                job.url,
                job.description,
                job.employment_type,
                job.seniority_level,
                json.dumps(asdict(job)),
            ),
        )
        conn.commit()
    
    
    def purge_expired(conn, retain_days):
        """Retention is a compliance requirement for personal data, not a nice-to-have."""
        cutoff = (date.today() - timedelta(days=retain_days)).isoformat()
        cursor = conn.execute(
            "DELETE FROM profiles WHERE scraped_at NOT BETWEEN ? AND date('now')",
            (cutoff,),
        )
        conn.commit()
        return cursor.rowcount

    Tip: Store the profile's public ID, not a name, as the join key. People change their display name and their headline constantly, and the URL slug is the closest thing the public surface has to a stable identifier.

  8. Scale Without Reaching for an Account

    At small volume this runs from a laptop. At volume the constraint moves to egress. The same public endpoints that answer a handful of requests from one address start returning 999 once the traffic behind that address stops resembling a person browsing.

    The usual response is a residential proxy pool. That fixes the address and leaves a seam somewhere else. In a proxy setup the browser runs on your infrastructure while the exit IP belongs to someone else's connection, so the TLS handshake, the canvas and WebGL output, the audio-context signature, and the screen geometry all describe your environment while the address says residential mobile. The two halves describe different machines, and that mismatch is itself a signal.

    Archonum closes the seam by not creating it. Sessions run on real consumer smartphones: Chrome for Android on the phone that owns the IP, one network hop, no proxy in between. The fingerprint is not synthesised to look real, it is produced by the hardware making the request. The network covers 250,000+ devices across 175+ countries (archonum.com, checked August 2026), and existing Playwright or Puppeteer scripts point at it without a rewrite.

    That is a claim about architecture, not about outcomes. What it buys you is that there is no synthetic layer to be caught out on. It is not a promise about how any particular site will behave, and it does not change the contract and privacy questions from step 1.

    Tip: If your 999 rate climbs as you add proxy IPs instead of falling, the refusal is keyed on something other than the address. More proxies will not fix a fingerprint problem.


LinkedIn's public surface is narrower than its internal API, and it is the part you can build on without having to argue about whether you were authorised to be there. Profile JSON-LD and the guest job endpoints cover a real range of work — job-market monitoring, company tracking, firmographic enrichment — and they answer without a session cookie.

The engineering that matters is unglamorous. Present a client that matches a real browser, pace against the status codes LinkedIn returns rather than a number you read somewhere, stop at the authwall, and store personal data as though a regulator will ask about it.

Everything past that line is a contract and compliance decision rather than a technical one, and hiQ is a standing reminder of how that decision can land. If your use case genuinely needs data that exists only behind a login, the honest paths are LinkedIn's partner APIs or a licensed provider.

FAQ

Two separate questions, and they came out differently. On the CFAA, the Ninth Circuit held in hiQ Labs v. LinkedIn (18 April 2022) that scraping publicly accessible pages is unlikely to be access "without authorization". On contract, the Northern District of California held in November 2022 that user-agreement terms prohibiting data scraping and fake profiles are enforceable through a breach-of-contract claim; the parties settled on 6 December 2022 with a $500,000 judgment against hiQ and injunctive relief barring it from scraping LinkedIn. Staying on public data reduces criminal-statute exposure. It does not remove contract exposure, and profile records are personal data under the GDPR either way. This is not legal advice.

Because there is no version of it that is not a breach of LinkedIn's User Agreement. Extracting li_at session cookies from a browser, automating a logged-in account, or spreading activity across a pool of accounts to stay under a limit all sit outside what the hiQ CFAA holding covers, and the November 2022 contract ruling points directly at them. If you need data that exists only behind a login, use LinkedIn's partner APIs or license it from a provider who has.

Yes, and that is the whole subject of this guide. Public profile pages under /in/ return a JSON-LD block with name, headline, location, employers, and education. The guest job endpoints return listings and full descriptions. None of it needs a session cookie. What you do not get is skills, endorsements, recommendations, contact details, connection graphs, or people search.

LinkedIn does not version these surfaces or announce changes to them, so treat any of it as able to break without notice. The JSON-LD block is the more durable half: it exists for search-engine indexing, which gives LinkedIn a standing reason to keep its shape stable. The CSS class names on the guest job cards are front-end implementation detail and carry no such guarantee. Parse defensively, treat missing fields as normal, and alert on a drop in overall parse rate rather than on individual failures.

Not datacenter. Those ranges are identifiable and get refused at the edge. Residential and mobile egress is the usual answer for public-page collection. The structural point is that a proxy only fixes the address: the browser fingerprint still comes from your own infrastructure, so the IP and the client can end up describing different machines. Real-device infrastructure avoids that split by running the browser on the device that owns the IP.

LinkedIn's Marketing and Talent APIs are restricted to approved partners with defined data scopes, and the approval process is slow. Many research and enrichment use cases are not covered. That gap is real, and it is why public-page collection exists. It is not a reason to treat a login as fair game: the gap is an argument for licensing the data or narrowing the use case, not for automating an account.

Run Public-Web Collection on Real Devices

Archonum runs agent browser sessions on real consumer smartphones: Chrome for Android on the phone that owns the residential IP, one network hop, no proxy split. 250,000+ devices across 175+ countries, drop-in for existing Playwright and Puppeteer scripts.