Python Web Scraping with Authentication | UnWeb

The gap between “scraping a public page” and “scraping login-gated content” trips up most developers who are new to scraping. The technical patterns aren’t complicated once you understand them, but they require a different mental model than what most tutorials cover.

This post covers the four authentication patterns you’ll encounter and how to handle each in Python using httpx for async HTTP and UnWeb for Markdown conversion once you’re authenticated.

Authorization matters: Only scrape content you’re authorized to access. Review the site’s Terms of Service. Scraping login-gated content without permission likely violates ToS and potentially applicable laws. The techniques in this post apply to your own accounts, authorized data collection, and sites that explicitly permit scraping.

The Four Authentication Patterns

Pattern 1: Session Cookies (the Common Case)

The most common authentication pattern for traditional web apps. When you log in, the server sets a Set-Cookie header with a session ID. Subsequent requests that include that cookie are treated as authenticated.

The key insight: use an httpx.AsyncClient (or requests.Session) so cookies are automatically preserved across requests. Don’t try to manage cookies manually.

import httpx
import asyncio

LOGIN_URL = "https://example.com/login"
PROTECTED_URL = "https://example.com/dashboard"

async def scrape_with_session():
    async with httpx.AsyncClient(
        follow_redirects=True,
        timeout=30,
        headers={"User-Agent": "Mozilla/5.0 (compatible; my-scraper/1.0)"}
    ) as client:
        # Step 1: POST login credentials
        login_resp = await client.post(
            LOGIN_URL,
            data={"username": "your_email@example.com", "password": "your_password"},
        )
        login_resp.raise_for_status()

        # The client now holds the session cookie automatically
        # Step 2: access protected content
        protected_resp = await client.get(PROTECTED_URL)
        protected_resp.raise_for_status()

        return protected_resp.text

html = asyncio.run(scrape_with_session())
print(html[:500])

Why follow_redirects=True? Login endpoints frequently redirect after successful authentication (302 → dashboard). Without follow_redirects, you get the redirect response instead of the destination page.

Finding the Login Endpoint and Form Fields

Open DevTools → Network tab → log in manually → find the POST request to the login endpoint. Inspect the request payload to see the exact form field names (they vary: username / email / user, password / passwd / pass).

Some sites include a CSRF token in the login form that must be extracted from the login page HTML before submitting:

from bs4 import BeautifulSoup

async def login_with_csrf(client: httpx.AsyncClient, login_page_url: str,
                           post_url: str, email: str, password: str):
    # Get the login page to extract the CSRF token
    page = await client.get(login_page_url)
    soup = BeautifulSoup(page.text, "html.parser")

    # Find the CSRF token — name varies: _token, csrf_token, authenticity_token
    csrf = soup.find("input", {"name": "_token"})
    if not csrf:
        raise ValueError("CSRF token not found")

    await client.post(
        post_url,
        data={"email": email, "password": password, "_token": csrf["value"]}
    )

Pattern 2: Saving and Reusing Session Cookies

For scrapers that run repeatedly, logging in every time is slow and may trigger rate limiting. Save the session cookies to disk after the first login and reuse them until they expire.

import json
import os
from pathlib import Path

COOKIE_FILE = Path("session_cookies.json")

def save_cookies(client: httpx.AsyncClient):
    cookies = {name: value for name, value in client.cookies.items()}
    COOKIE_FILE.write_text(json.dumps(cookies))

def load_cookies() -> dict | None:
    if not COOKIE_FILE.exists():
        return None
    try:
        return json.loads(COOKIE_FILE.read_text())
    except Exception:
        return None

async def get_authenticated_client(login_url: str, email: str, password: str) -> httpx.AsyncClient:
    client = httpx.AsyncClient(follow_redirects=True, timeout=30)

    saved_cookies = load_cookies()
    if saved_cookies:
        for name, value in saved_cookies.items():
            client.cookies.set(name, value)
        # Quick check: try an authenticated request
        try:
            test = await client.get("https://example.com/api/me")
            if test.status_code == 200:
                return client  # Cookies still valid
        except Exception:
            pass

    # Cookies expired or don't exist — re-login
    await client.post(login_url, data={"email": email, "password": password})
    save_cookies(client)
    return client

Pattern 3: Bearer Token Authentication

Modern apps and APIs often use JWT or OAuth access tokens. You get a token from a login or token endpoint, then pass it as an Authorization header on every subsequent request.

import httpx
import time
import asyncio

TOKEN_URL = "https://api.example.com/auth/token"
API_BASE = "https://api.example.com/v1"

async def get_access_token(client_id: str, client_secret: str) -> tuple[str, float]:
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            TOKEN_URL,
            json={"client_id": client_id, "client_secret": client_secret,
                  "grant_type": "client_credentials"}
        )
        resp.raise_for_status()
        data = resp.json()

    token = data["access_token"]
    # expires_in is seconds from now; store absolute expiry time
    expires_at = time.time() + data.get("expires_in", 3600) - 60  # 60s buffer
    return token, expires_at

class AuthenticatedClient:
    def __init__(self, client_id: str, client_secret: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self._token: str | None = None
        self._expires_at: float = 0

    async def get_token(self) -> str:
        if not self._token or time.time() >= self._expires_at:
            self._token, self._expires_at = await get_access_token(
                self.client_id, self.client_secret
            )
        return self._token

    async def get(self, url: str, **kwargs) -> httpx.Response:
        token = await self.get_token()
        async with httpx.AsyncClient() as client:
            return await client.get(
                url,
                headers={"Authorization": f"Bearer {token}"},
                **kwargs
            )

# Usage
async def main():
    auth_client = AuthenticatedClient("my_client_id", "my_client_secret")
    resp = await auth_client.get(f"{API_BASE}/users/me")
    print(resp.json())

Pattern 4: API Key Authentication

The simplest pattern. A static key is passed as a header, query parameter, or in a custom header. The key doesn’t expire unless rotated manually.

import httpx
import os

API_KEY = os.environ["MY_API_KEY"]  # Never hardcode keys in source

# Pattern A: Header (most common)
async def api_key_header(url: str) -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, headers={"X-API-Key": API_KEY})
        resp.raise_for_status()
        return resp.json()

# Pattern B: Query parameter
async def api_key_query(url: str) -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, params={"api_key": API_KEY})
        resp.raise_for_status()
        return resp.json()

# Pattern C: Bearer token (when key is used as a bearer)
async def api_key_bearer(url: str) -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            url,
            headers={"Authorization": f"Bearer {API_KEY}"}
        )
        resp.raise_for_status()
        return resp.json()

Combining Auth with UnWeb for Clean Markdown

Once you have an authenticated session, you can pass those cookies to the UnWeb API to convert login-gated pages to Markdown. UnWeb accepts a cookies parameter for this purpose:

import httpx

UNWEB_API_KEY = "your_unweb_key"

async def fetch_authenticated_markdown(
    url: str,
    session_cookies: dict
) -> str:
    async with httpx.AsyncClient(timeout=30) as client:
        resp = await client.post(
            "https://api.unweb.info/v1/convert",
            headers={"Authorization": f"Bearer {UNWEB_API_KEY}"},
            json={
                "url": url,
                "cookies": session_cookies  # Pass your authenticated cookies
            }
        )
        resp.raise_for_status()
        return resp.json()["markdown"]

# Usage with the session pattern
async def main():
    async with httpx.AsyncClient(follow_redirects=True) as client:
        await client.post(
            "https://example.com/login",
            data={"email": "you@example.com", "password": "your_pass"}
        )
        # Extract cookies as dict
        cookies = dict(client.cookies)

    # Now fetch login-gated content as clean Markdown
    markdown = await fetch_authenticated_markdown(
        "https://example.com/members/dashboard",
        cookies
    )
    print(markdown[:500])

Common Authentication Debugging Tips

UnWeb: Markdown Conversion for Authenticated Pages

Pass your session cookies and get clean Markdown back from login-gated content — no HTML parsing, no CSS selectors.

Get started free