November 1, 20268 min readscrapingbrowsertutorial
How to Scrape a Website That Requires a Login (Step by Step, With Code)
Sign in once in a real browser, save the login, and replay it from the same IP on every run — with working Python, and what to do when the site signs you out.
By Scrappy Team
The short answer: use a real browser, sign in once by hand, save that signed-in state, and start every later run from it — from the same IP address the login was made on. Then watch for the day the site signs you out, because it will.
Everything below is that answer in working code. It uses Scrappy's cloud browser API, but the steps are the same whatever you run them on; the parts that matter are the fixed IP and noticing the sign-out.
Why the usual approaches break
There are three things people try first, and each fails in its own way.
- Fetch the page with an HTTP client and a pasted cookie. Works for a day. The page is usually rendered by JavaScript, so the HTML you fetch has no data in it — and the cookie stops working as soon as the site notices it is arriving from a different IP and a different client than the one that signed in.
- Script the login form every run. Logins are the most defended page on any site. Repeated scripted sign-ins trigger two-factor prompts, CAPTCHAs and account locks, and each run of your scraper looks like a new device signing in.
- Run headless Chrome on a server. Better, but a datacentre IP and a headless browser are both easy to recognise, and a login replayed from a cloud IP range is precisely what a risk engine is built to question.
What a site expects to see is a returning visitor: the same browser, from the same place, already signed in. So that is what to give it.
The approach
- Open a real (headful) browser on a residential IP.
- Sign in once, as a person — in a live view of that browser, so two-factor codes and security questions are answered by you.
- Save the signed-in state: cookies and localStorage, plus the IP and user agent it was created with.
- Start every later run from that saved state, through the same IP.
- When a run lands on the sign-in page instead of the app, stop and ask a person to sign in again — do not overwrite the saved login with a signed-out one.
Setup
You need an API key with the browser:read and browser:write scopes (Settings → API keys). A new account has 100 free credits; a session costs 5 to start and 1 per minute.
import os, time, requests
API = "https://scrappy.gg/api/browser"
H = {"Authorization": f"Bearer {os.environ['SCRAPPY_API_KEY']}"}
def call(method, path, **kw):
r = requests.request(method, f"{API}{path}", headers=H, timeout=90, **kw)
r.raise_for_status()
body = r.json()
return body.get("data", body) # most routes wrap their answer in "data"
Step 1: create a profile to hold the login
A profile is an empty container until you capture a login into it. Name it after the account and the site.
profile = call("POST", "/profiles", json={"name": "acme-portal (ops@yourco)"})
profile_id = profile["id"]
Step 2: sign in once, by hand
Start a session, mint a viewer link and open it. The viewer is the live browser: click and type in it exactly as you would on your own machine, including the two-factor code.
created = call("POST", "/sessions", json={})
session_id = created["session"]["id"] # not created["id"]
call("POST", f"/sessions/{session_id}/navigate",
json={"url": "https://portal.example.com/login", "waitUntil": "domcontentloaded"})
viewer = call("POST", f"/sessions/{session_id}/viewer-token")
print("Sign in here:", viewer["viewerUrl"]) # valid for ~5 minutes; mint a new one if it lapses
input("Press Enter once you can see the signed-in page...")
The link works on a phone too, which matters more than it sounds: the person who owns the account is often not the person running the script, and a text message is the fastest way to reach them.
Step 3: capture the login
saved = call("POST", f"/profiles/{profile_id}/capture", json={"sessionId": session_id})
print(saved) # {"captured": true, "cookieCount": 23, "originCount": 2}
call("DELETE", f"/sessions/{session_id}")
The capture records the session's cookies and per-origin localStorage, encrypted, and also the exit IP and user agent the session used. You do not choose the IP; it is whatever the login was made from, and that is the point. A capture that comes back empty is refused rather than saved, so a hiccup cannot wipe a working login.
Step 4: scrape, already signed in
Every later run passes the profileId. The session starts with the cookies in place before the first page loads, through the same IP as the login.
def scrape_orders():
created = call("POST", "/sessions", json={"profileId": profile_id})
sid = created["session"]["id"]
if not created.get("pinned"):
print("warning: this run is not on the login's own IP; expect a challenge")
try:
call("POST", f"/sessions/{sid}/navigate",
json={"url": "https://portal.example.com/orders", "waitUntil": "domcontentloaded"})
# Wait for the data, not just the page: the table renders after the bundle runs.
call("POST", f"/sessions/{sid}/wait-for-selector",
json={"selector": "table.orders tbody tr", "timeout": 15000})
rows = call("POST", f"/sessions/{sid}/evaluate", json={"expression": """
JSON.stringify([...document.querySelectorAll('table.orders tbody tr')].map(tr =>
[...tr.cells].map(td => td.innerText.trim())))
"""})["result"]
return rows
finally:
call("DELETE", f"/sessions/{sid}")
Two details in there save hours:
waitUntil: "domcontentloaded", then a wait for the element you need. A navigation returns as soon as the new document exists, before any script has run. Reading the page at that moment gives you an empty table and no error.- Release in
finally. A session you forget keeps running until it is reaped for idleness — and keeps billing until then.
When the site rotates its session cookie during the run (many do), the new one is written back into the profile when the session is released, so the next run starts from the freshest state rather than the one you captured a week ago.
Step 5: notice when the site signs you out
Every login ends eventually — a password change, a security sweep, a fixed 30-day expiry. The failure to design for is the quiet one: a scraper that keeps running on the sign-in page and records "no orders" for a week.
Scrappy watches for it. When a session started from a profile lands on a sign-in page instead of the app, it refuses to save the signed-out state over the good one and sends a BROWSER_LOGIN_LOST webhook:
curl -X POST https://scrappy.gg/api/user/webhooks \
-H "Authorization: Bearer $SCRAPPY_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Logins", "url": "https://yourco.com/hooks/scrappy",
"secret": "a-long-random-string", "events": ["BROWSER_LOGIN_LOST"]}'
The payload names the profileId, the site's origin and the path it was bounced to. The usual handler sends the account owner a fresh viewer link (step 2 again) and pauses the job until they have signed in. Capturing into the same profile keeps its IP.
When it still gets challenged
- A run fails with
503 UPSTREAM_UNAVAILABLEon navigate. The route to the site failed, not the login. Retry the same session; if it persists, create the next one with"rotateEgress": truefor that run only. The login travels with it, and the pin is restored by the next capture. pinned: falseon create. The login's IP is cooling down after a failure. Wait a few minutes rather than replaying the login from somewhere new.- A CAPTCHA appears.
POST /sessions/{id}/captchatells you whether one is actually rendered (most sign-in pages load the vendor's script whether or not a challenge shows). A press-and-hold wall needs a person: send the viewer link. - Two-factor every single time. The site is treating each run as a new device. Check that
pinnedis true, and that you are not clearing cookies or rotating the exit by default.
Is this allowed?
Automating your own account, or one you are authorised to use, is a question of the site's terms of service — read them. Keep saved logins as carefully as passwords: a session cookie grants access without passing two-factor, which is why Scrappy encrypts them at rest, never returns them from any endpoint, and deletes them when you delete the profile.
Summary
- Hold the login:
POST /api/browser/profiles - Sign in by hand:
POST /sessions,/navigate,/viewer-token - Save it:
POST /profiles/{id}/capture - Scrape signed in:
POST /sessionswithprofileId, then/navigate,/wait-for-selector,/evaluate - Hear when it ends:
BROWSER_LOGIN_LOSTwebhook
The full reference is in the saved logins guide and the cloud browser guide. If you are driving the browser from an AI agent rather than a script, the MCP server exposes the same calls as tools.
Keep reading
When Your Browser Agent Gets Stuck, Hand the Browser to a Person
CAPTCHAs, two-factor codes and press-and-hold walls stop every browser agent. How to detect the wall, text a person a live link, wait, and carry on in the same session.
scrapingHeadless Chrome Is Why You're Getting Blocked
You can patch every fingerprint you know about and still be detected, because headless is visible on its own. What we changed after chasing this for months.
scrapingYour Scraper Can't Log In. That's Where the Good Data Is.
Everything easy to scrape has already been scraped. The contacts worth having sit behind a sign-in — and that is a different engineering problem.