Sticky Session IP Changing Mid-Flow? Causes and Fixes
Sticky session IP changing mid-login or checkout? Check leaks, session ID format, the 5-minute window, parallel connections and exit churn in order.
A sticky session IP changing halfway through a login or checkout usually shows up as something else first: a forced logout, an emptied cart, a fresh CAPTCHA on the payment step, or a "verify it's you" email. This guide walks the causes in the order that's cheapest to check, says which ones a proxy setting can fix and which it can't, and ends with a script that tells you how your sticky port really behaves plus a recovery plan for the drops you can't prevent.
What the Break Looks Like From Your Side
Sites rarely tell you "your IP changed." They tell you something about your session instead. Typical shapes of the problem:
- Step 3 of a checkout returns a 302 back to
/loginor/cart, with no error in your logs. - A 403 or a challenge page appears on the payment step after the product and cart pages loaded fine.
- The response body contains text like "Your session has expired" or "Please sign in again" a minute or two after a successful login.
- An account receives a "new sign-in from a different location" notice during an automated run.
- A CSRF or form token is rejected ("Invalid token", "Form has expired") even though you fetched it seconds earlier.
None of those prove an IP change. Cookie loss, a token you didn't carry forward, or a fingerprint mismatch produce the same screens. So the first job is evidence, not fixes. If you're still deciding whether a flow needs stickiness at all, our explainer on what a sticky session proxy is covers the model in a few minutes.
Prove the Exit Changed Before You Fix Anything
Add one line of logging per step: the exit address the target would see, fetched through the exact client object the flow uses. Don't test with a separate curl in another terminal; that measures a different connection.
import time
import requests
PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002" # Sticky Sessions port
ECHO = "https://api.ipify.org?format=json"
session = requests.Session()
session.proxies = {"http": PROXY, "https": PROXY}
started = time.monotonic()
def exit_ip(label: str) -> str:
ip = session.get(ECHO, timeout=15).json()["ip"]
print(f"{time.monotonic() - started:7.1f}s {label:<14} {ip}")
return ip
exit_ip("before-login")
# session.post("https://shop.sparkproxy.io/login", data={...})
exit_ip("after-login")
# session.get("https://shop.sparkproxy.io/cart")
exit_ip("before-payment")
Run the flow three times. You'll get one of three pictures, and each points somewhere different:
| What the log shows | Where to look |
|---|---|
| One IP for every step, but the site still logs you out | Not an IP problem. Cookies, tokens or fingerprint. |
| IP changes at the same elapsed time on every run | The session window (see the 5-minute section) |
| IP changes at a step that runs in parallel or after a pause | Connection handling in your client |
| IP changes at random points, sometimes not at all | Exit churn, or a leak that only some requests hit |
If the IP never changes and you still get logged out, stop reading this guide and look at your cookie jar. Our guide to handling cookies and sessions while scraping is the right next stop.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Six Checks, Cheapest First
Work down this table. Each row takes less effort than the one below it, and the last column is the honest answer to "can the proxy fix this?"
| # | Check | Effort | Points to | Fixable at the proxy? |
|---|---|---|---|---|
| 1 | Does every request (including HTTPS and browser sub-requests) go through the proxy? | 2 minutes | Leaks | No. It's your client config. |
| 2 | Is the proxy URL byte-identical at every step (port, username, country suffix)? | 5 minutes | Formatting | Partly. The fix is in your code. |
| 3 | Does the change happen at a consistent elapsed time? | One timed run | Session window | No. A longer flow needs a shorter design. |
| 4 | Does the change line up with parallel requests or a pause? | One run with the lab settings | Connection pooling | No. Client settings. |
| 5 | Does the verification script below show the port holding steady on its own? | 6 minutes | Gateway behaviour | Yes, if the gateway itself is flapping. Report it. |
| 6 | Changes left over, at random, rarely | Days of logs | Exit churn | Reduced, never zero |
Most cases end at row 1, 2 or 3.
Requests Leaking Around the Proxy
A "changing IP" is often your own address appearing between two proxied requests. The target sees exit A, then your office IP, then exit A again, and treats the session as hijacked.
The classic Python version is a proxies dict with only one key:
# Broken: only http:// URLs use the proxy. Every https:// request goes direct.
session.proxies = {"http": "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002"}
# Fixed: both schemes, same sticky URL.
STICKY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002"
session.proxies = {"http": STICKY, "https": STICKY}
session.trust_env = False # ignore HTTPS_PROXY / NO_PROXY from the shell
trust_env = False matters more than it looks. With it left on, requests reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment, so a CI runner with a corporate NO_PROXY list can send one domain of your checkout (often the payment provider) straight out.
Other leak paths we see:
- Two clients, two configs. The login runs in Playwright on the sticky port, then a "faster" requests call fetches an API endpoint through
HTTPS_PROXY, which points at the Random Rotation port 11000. Same flow, two exit strategies. - Payment iframes on another domain. A browser proxy covers them; a hand-rolled HTTP flow that skips the iframe and calls the provider's API from a different client may not.
- WebRTC. In a real browser profile, WebRTC can reveal a local or public address that differs from the proxy exit. Disable it in the profile if the target runs IP-consistency checks.
A two-minute check from the shell confirms which exits each client produces:
curl -s -x http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002 https://api.ipify.org; echo
curl -s https://api.ipify.org; echo # your own address, for comparison
env | grep -i -E '^(https?|no)_proxy=' # what your scripts inherit
If your own address appears anywhere in the per-step log from the previous section, you've found the cause. No proxy setting will fix it.
Session ID and Username Formatting Mistakes
On SparkProxy, the session mode is chosen by port on gateway.sparkproxy.io: 11000 is Random Rotation over HTTP/HTTPS, 11002 is Sticky Sessions, and 13000 is SOCKS5. The username carries targeting, shown on our homepage connection panel in the form user-country-us. Other gateways put a session label in the username too, typically user-session-, and the exact syntax differs per provider. Check your provider's dashboard rather than copying a format from a forum post.
Whatever the syntax, the same handful of mistakes break stickiness:
| Mistake | What happens | Fix |
|---|---|---|
| The session ID is generated inside the request function | Every request asks for a new session | Generate it once per flow and pass it down |
| Country chosen at random per request | Each step lands in a different country's pool | Pick the country once per flow |
| The ID contains the provider's delimiter (`-`, `_`) | The gateway reads part of your ID as another parameter, or rejects it | Use a plain alphanumeric ID |
| Password has `@`, `:` or `/` and isn't URL-encoded | The proxy URL parses wrongly; some steps fail over to a direct connection or another config | Percent-encode credentials |
| Trailing newline from an env file | The username differs between processes that strip it and ones that don't | `.strip()` on load |
Build the URL in one place and log a hash of it at each step. If the hash changes, you've found the bug before you've looked at a single exit IP:
import hashlib
import secrets
from urllib.parse import quote
def flow_proxy_url(user: str, password: str, country: str = "us") -> str:
"""Build the proxy URL once per flow. Never call this per request."""
username = f"{user.strip()}-country-{country.lower()}"
return (f"http://{quote(username, safe='')}:{quote(password.strip(), safe='')}"
f"@gateway.sparkproxy.io:11002")
def url_fingerprint(url: str) -> str:
return hashlib.sha256(url.encode()).hexdigest()[:10]
flow_id = secrets.token_hex(6) # plain hex, no delimiters
proxy_url = flow_proxy_url("USERNAME", "p@ss:word")
print(flow_id, url_fingerprint(proxy_url)) # log this next to every step
The flow_id isn't sent to the proxy here; it labels your logs. If your provider uses a username session label, flow_id is a safe value to put there because hex never contains a hyphen or underscore.
The Flow Outlasts the 5-Minute Window
SparkProxy's Sticky Sessions hold an exit and then rotate it automatically on a 5-minute cycle. Our product page describes the two modes as "Fresh IP per request, or auto-rotate every 5 min" (read from sparkproxy.io on 2026-10-30). Any flow that takes longer than the window will cross a rotation, and nothing at the proxy layer stretches it.
Flows that run long are usually long for human reasons: an emailed one-time code, a 3-D Secure challenge, a human-like delay you added between every click, or a queue page. Time each step and compare the total to the window:
import time
class SessionBudget:
"""Track elapsed time against a sticky window and refuse to start late steps."""
def __init__(self, window_s: float = 300.0, margin_s: float = 45.0):
self.window_s = window_s
self.margin_s = margin_s
self.t0 = time.monotonic()
def remaining(self) -> float:
return self.window_s - (time.monotonic() - self.t0)
def require(self, step: str, needs_s: float) -> None:
left = self.remaining()
if left - self.margin_s < needs_s:
raise TimeoutError(f"{step}: needs {needs_s:.0f}s, only {left:.0f}s left in window")
budget = SessionBudget()
budget.require("login", needs_s=20)
# ... login ...
budget.require("payment", needs_s=60) # raises before you start a step you can't finish
The window starts when the exit is assigned, which may be before your first request if other traffic on the same session already touched it. That's why the budget uses a margin, and why the verification script later measures the real hold time instead of trusting the label.
Ways to fit a flow inside the window, roughly in order of impact:
- Do the anonymous part elsewhere. Browsing, search and product pages rarely need stickiness. Fetch them on Random Rotation and start the sticky clock at login.
- Cut artificial delays on the critical path. A 4-second "human" pause between each of 30 clicks is two minutes of your budget.
- Pre-fetch what you can. Read the OTP mailbox via API rather than polling a webmail page through the same proxy.
If a flow genuinely can't fit (a long queue page, for instance), a rotating proxy pool is the wrong tool for that step. Our look at how long proxy IPs last explains where a static, leased address fits instead.
Parallel Connections and Idle Gaps (Measured)
Whether a new TCP connection to the gateway keeps your exit depends on how the gateway keys a session: by credentials or label, or per connection. If it's per connection, every new tunnel is a new IP. Your HTTP library decides how many tunnels a flow opens, and most of them open more than you'd guess.
We measured this on 2026-10-30 on one Windows Server 2022 machine, with a local HTTPS echo server behind a small local CONNECT proxy that logged one line per new tunnel. Each client sent three sequential HTTPS requests with no gap, the same three with 7-second gaps, three with 16-second gaps (requests and aiohttp only), and five concurrent requests. Versions: Python 3.14.2, requests 2.32.5 with urllib3 2.6.3, httpx 0.28.1, aiohttp 3.14.5.
| Client | 3 requests, no gap | 3 requests, 7 s gaps | 3 requests, 16 s gaps | 5 concurrent |
|---|---|---|---|---|
| requests `Session` | 1 tunnel | 1 tunnel | 1 tunnel | 5 tunnels |
| httpx `Client` | 1 tunnel | 3 tunnels | not run | 5 tunnels |
| aiohttp `ClientSession` | 1 tunnel | 1 tunnel | 3 tunnels | 5 tunnels |
The idle results match each library's defaults, which we read from the installed source the same day: httpx's Limits sets keepalive_expiry=5.0 seconds, aiohttp's TCPConnector sets keepalive_timeout to 15.0 seconds when you don't pass one, and Node's undici 8.12.3 client defaults keepAliveTimeout to 4,000 ms. urllib3 has no client-side idle expiry, so requests only re-tunnels when the server or proxy closes the connection. Our local server never closed idle connections; real servers and gateways do, so expect requests to re-tunnel on long pauses too.
Two lessons for a sticky flow:
- Concurrency inside a flow multiplies tunnels. Fetching the cart, shipping options and a price check with
asyncio.gatheropened five tunnels in every client. On a per-connection gateway, that's up to five exits in one step. - A pause longer than the keep-alive timeout opens a new tunnel. A user-paced 7-second wait was enough to make httpx reconnect before every request.
Pin a flow to one connection where your library allows it:
import asyncio
import aiohttp
import httpx
STICKY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002"
# httpx: one connection, kept alive across user-paced pauses.
flow_client = httpx.Client(
proxy=STICKY,
limits=httpx.Limits(max_connections=1, max_keepalive_connections=1,
keepalive_expiry=300.0),
)
# aiohttp: serialise the flow and keep the tunnel for the whole window.
async def run_flow() -> None:
connector = aiohttp.TCPConnector(limit=1, keepalive_timeout=300)
async with aiohttp.ClientSession(connector=connector) as s:
for path in ("/login", "/cart", "/checkout"):
async with s.get(f"https://shop.sparkproxy.io{path}", proxy=STICKY) as r:
await r.read()
# asyncio.run(run_flow())
Browsers are harder to pin. Chromium opens several connections per origin and more for third-party assets, so in Playwright give each identity its own browser context with the sticky proxy set on that context, and never share a context between flows:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ // one context = one identity = one flow
proxy: {
server: 'http://gateway.sparkproxy.io:11002',
username: 'USERNAME-country-us',
password: process.env.SPARK_PASS,
},
});
const page = await ctx.newPage();
await page.goto('https://shop.sparkproxy.io/login');
// ... the rest of the flow, then:
await ctx.close();
await browser.close();
})();
A context per identity doesn't reduce the connection count; it keeps one flow's connections on one proxy configuration. Whether those connections share an exit is exactly what the verification script answers. The session-aware rotation guide for JavaScript-rendered sites goes deeper on browser wiring.
Exit Node Churn
Even a correctly configured sticky session depends on one exit staying healthy for the length of the window. Datacenter exits get pulled for maintenance, fail health checks, or get rotated out of a pool, and when that happens mid-session the gateway has two choices: fail your request or move you to another exit. Moving you keeps the scraper running and breaks the login.
You can't configure churn away. What you can do:
- Measure it. Log the exit at every step for a few days. Changes that occur before the window ends, at no consistent time, with leaks and formatting ruled out, are churn.
- Keep flows short. The probability of hitting a churn event scales with how long you hold the exit.
- Treat a mid-window change as a reportable event. If the verification script shows the port changing well before 5 minutes repeatedly, send the timestamps and exits to support@sparkproxy.io. That's data we can act on.
We don't publish a churn rate, and we'd be suspicious of anyone who quotes one without saying how they measured it.
Scraping API: Why session_id Won't Pin a Flow
If you run the flow through the Scraping API rather than raw proxies, there's a separate trap. The session_id parameter looks like it should hold a session together across requests. It doesn't. The API documentation says it "Labels this job's browser profile in per-job logs," and that "Each request gets a fresh browser profile regardless of the session_id value. Cookies and login state do not persist across separate API requests."
So a login call followed by a dashboard call, both with the same session_id, are two unrelated browsers. The fix is to run the whole multi-step flow inside one request with js_scenario, which needs render_js=true (the default), costs +5 credits on top of the 5-credit rendered request, and accepts up to 50 instructions:
import base64
import requests
scenario = {"instructions": [
{"wait_for": "#email"},
{"fill": {"selector": "#email", "value": "buyer@sparkproxy.io"}},
{"fill": {"selector": "#password", "value": "CHANGE_ME"}},
{"click": "#login-submit"},
{"wait_for": ".account-menu"},
{"evaluate": "location.assign('/orders')"},
{"wait_for": ".order-list"},
]}
r = requests.post(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
json={"url": "https://shop.sparkproxy.io/login",
"render_js": True,
"js_scenario": scenario,
"json_response": True},
timeout=200,
)
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
print(env["status_code"], env["credits_used"], "order-list" in html)
Two details from the docs matter here. With json_response=true you get the envelope (job_id, status_code, duration_ms, credits_used, body), and the body is base64, so decode it before you search for markers. And the docs state that page navigation is retried server-side up to three times, so a scenario should be safe to re-run. Don't put an irreversible "place order" click in a scenario you haven't tested end to end.
A Verification Script for Your Sticky Port
This script answers three questions about the port you're paying for, without any of your flow's code in the way: how long an exit really holds, whether a brand-new connection keeps the exit, and whether concurrent connections share it. Run it with your real credentials for about six minutes.
"""sticky_probe.py: measure how a sticky proxy port actually behaves."""
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import requests
ECHO = "https://api.ipify.org?format=json"
def ip_via(proxy: str, session: requests.Session | None = None) -> str:
s = session or requests.Session()
s.trust_env = False
try:
return s.get(ECHO, proxies={"http": proxy, "https": proxy}, timeout=15).json()["ip"]
except requests.RequestException as exc:
return f"ERR:{type(exc).__name__}"
finally:
if session is None:
s.close()
def main(proxy: str, minutes: float = 6.0, every: float = 15.0) -> None:
reused = requests.Session()
t0 = time.monotonic()
last_fresh, changes = None, []
while time.monotonic() - t0 < minutes * 60:
t = time.monotonic() - t0
fresh = ip_via(proxy) # new TCP connection each time
kept = ip_via(proxy, reused) # same pooled connection
with ThreadPoolExecutor(5) as ex: # five new connections at once
burst = set(ex.map(lambda _: ip_via(proxy), range(5)))
if last_fresh and fresh != last_fresh:
changes.append(round(t))
last_fresh = fresh
print(f"{t:6.0f}s fresh={fresh:<16} reused={kept:<16} burst={sorted(burst)}")
time.sleep(every)
print("exit changed at (s):", changes or "never")
if __name__ == "__main__":
main(sys.argv[1])
Run it as python sticky_probe.py "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002". Read the output like this:
| Pattern | Meaning | What to do |
|---|---|---|
| `fresh`, `reused` and `burst` all show one IP, changing about every 5 minutes | Session keyed by credentials; behaving as designed | Fix your flow length and leaks; the port is fine |
| `reused` steady, `fresh` and `burst` vary | Session keyed per connection | Pin each flow to one connection (previous sections) |
| Changes well under 5 minutes, at irregular times | Churn or a gateway problem | Collect the log and contact support |
| `ERR:` entries mixed in | Auth or connectivity failures | Fix these first; a failed request can push clients onto fallback paths |
The probe uses api.ipify.org because it returns only the address. Any echo endpoint works, as long as every request in the flow and the probe use the same one.
Recovery Plan When the IP Still Drops
After the fixes above, drops get rare, not impossible. A flow that touches a login or a payment needs a plan for the remaining cases, decided before the run rather than inside an exception handler at 3 a.m.
- Check the exit before every irreversible step. Compare it with the address you logged at login. If it differs, stop before the click, not after.
- Abandon, don't patch. Close the browser context or HTTP session and discard its cookie jar. Replaying cookies earned on one address from another is the pattern account-security systems look for.
- Check state before retrying. For a checkout, query the order history or confirmation endpoint first. A retry that submits a second order is worse than the dropped session.
- Restart from login on a new session, with a fresh budget timer.
- Cap retries per account. Two restarts in an hour is a reasonable ceiling; past that, park the account and alert a human, because repeated "new location" logins compound.
A guard that implements steps 1 and 2:
class ExitChanged(RuntimeError):
pass
def guarded_step(session, expected_ip: str, step_name: str, action):
"""Run an irreversible action only if the exit still matches the login IP."""
now = session.get("https://api.ipify.org?format=json", timeout=15).json()["ip"]
if now != expected_ip:
session.cookies.clear()
session.close()
raise ExitChanged(f"{step_name}: exit moved {expected_ip} -> {now}; flow abandoned")
return action()
Track how often ExitChanged fires per thousand flows. A rising rate almost always means flows have grown longer than the window, which is a scheduling problem you can fix.
On cost: SparkProxy plans are sold as packs starting from $75/month (USD, read from sparkproxy.io on 2026-10-30) with unlimited bandwidth, so restarting a flow costs time and concurrency rather than per-GB traffic. The 24-hour free trial needs no credit card, which is enough time to run the probe against port 11002 before you commit. If your work suits a request-per-page model, a proxy rotation interval chosen per task may serve you better than holding sessions at all.
Frequently asked questions
FAQ
Yes, on SparkProxy. Sticky Sessions rotate the exit automatically on a 5-minute cycle, so any flow longer than that crosses a rotation. Changes well before 5 minutes, at irregular times, point to leaks, connection handling or exit churn instead.
Concurrent requests open separate connections; in our test, five concurrent requests opened five tunnels in requests, httpx and aiohttp alike. If the gateway keys sessions per connection, each tunnel can get its own exit. Serialise the steps of one flow or cap the pool at one connection.
It can. httpx closes idle connections after 5 seconds by default, aiohttp after 15 and Node's undici after 4, and the next request opens a new tunnel. Whether that tunnel keeps the exit depends on how the gateway keys sessions, which the verification script shows.
No. The documentation says each request gets a fresh browser profile and that cookies and login state don't persist between API requests. Run a multi-step flow inside a single request with js_scenario instead.
Log the exit address at every step through the same client. If the address never changes and you're still logged out, the cause is cookies, tokens or fingerprinting on the site side, and no proxy setting will help.
No. Discard the session and its cookies, check the order history so you don't buy twice, and restart from login on a new session. Carrying cookies onto a new address is the pattern fraud checks look for.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
httpx proxies Argument Removed? Fix ProxyError and 407
The httpx proxies argument is gone in 0.28. Fix the TypeError, then ProxyError 407, SOCKS ImportError and RemoteProtocolError, with verbatim errors and code.
npm config set proxy: Install Packages Behind a Proxy
npm config set proxy and https-proxy, the right .npmrc file, yarn and pnpm equivalents, noproxy for private registries, and keeping passwords out of your repo.
Rotating Proxy Not Rotating? Fix Same-IP Responses
Rotating proxy same IP every request? Your client reuses one CONNECT tunnel or a sticky setting survived. Fix it in requests, httpx, Node, Playwright.
