ASGI middleware that scores every request inside your own process, against a policy it synchronizes from the control plane and caches on disk, and enforces the decision before any of your routes run. No proxy, no DNS change, and no round trip on the hot path: a visitor who has already passed the challenge is recognised from a signed cookie the agent verifies locally.
from fastapi import FastAPI
from relintio_agent.asgi import UltimateProtectorMiddleware
app = FastAPI()
app.add_middleware(
UltimateProtectorMiddleware,
license_key="UP_LIVE_...",
api_url="https://api.relintio.com/v1",
)pip install relintio-agentPython 3.10 or newer. Depends on httpx and cryptography.
This is a server-side SDK. It holds your licence key, which never leaves the server: it is the HMAC key that signs every outbound call and mints every passport, and the browser SDKs deliberately carry a publishable key instead. If a licence key would end up in a bundle, in a template, or in a URL, that is the bug — not a configuration to work around.
add_middleware wraps the whole application, so it covers every route regardless of where this call sits relative to your route definitions. It does have to run before the app starts serving requests.
import os
from fastapi import FastAPI
from relintio_agent.asgi import UltimateProtectorMiddleware
app = FastAPI()
app.add_middleware(
UltimateProtectorMiddleware,
license_key=os.environ["UP_LICENSE_KEY"],
api_url=os.environ["UP_API_URL"],
except_paths=["/healthz", "/webhooks/*"],
)Exclude health checks and inbound webhooks explicitly, as above. Machine callers score as bots, and the alternative — lowering protection globally to stop paging yourself — protects nobody.
If you can change how the app is created but would rather keep configuration out of code:
from fastapi import FastAPI
from relintio_agent import wrap_asgi_app
app = wrap_asgi_app(FastAPI())UP_LICENSE_KEY=UP_LIVE_...
UP_API_URL=https://api.relintio.com/v1wrap_asgi_app returns the app untouched when UP_LICENSE_KEY or UP_API_URL is missing, and when UP_AGENT_DISABLE is set. That is deliberate — an app that will not boot because a key is absent is a worse outage than an app that boots unprotected — but it means a typo in an environment variable name looks exactly like a working install. Verify against the control plane before calling it done.
The Python bundle from Dashboard → Deployment → Python ships a sitecustomize.py. Put its directory on PYTHONPATH, set UP_ASGI_APP="module:app", and it imports that object on interpreter startup, wraps it, and writes it back. Every failure path in it is a silent pass — it is a convenience, not a guarantee.
| Option | Environment | Default | Meaning |
|---|---|---|---|
license_key |
UP_LICENSE_KEY |
— | Required. |
api_url |
UP_API_URL |
— | Required. https://api.relintio.com/v1. |
sync_interval_seconds |
UP_SYNC_INTERVAL_SECONDS |
10 |
Policy refresh interval. Floored at 10. |
only_paths |
UP_ONLY_PATHS |
— | Protect only these. Exact (/checkout) or prefix (/product/*). |
except_paths |
UP_EXCEPT_PATHS |
— | Never protect these. Wins over only_paths. |
only_regex |
UP_ONLY_REGEX |
— | Protect only paths matching this Python regex. |
| — | UP_AGENT_DISABLE |
— | wrap_asgi_app returns the app unwrapped. |
Environment lists are comma- or newline-separated, and the UP_ prefix is configurable via config_from_env(prefix=...).
Clean traffic is sampled at 1%, and that is not configurable. ALLOW_SAMPLE_RATE in ultimateprotector_agent.client is fixed at 0.01, matching UsageMeterService::ALLOW_SAMPLE_RATE on the platform and AgentPayloadService::LOG_ALLOW_SAMPLE_RATE in the compiled engine. The platform multiplies a reported allow back up by that rate to estimate real traffic, so an install choosing its own rate reports a number the platform then corrects by the wrong constant — silently, on that customer's bill. The allow_sample_rate keyword and the UP_ALLOW_SAMPLE_RATE environment variable are gone for that reason. Blocks, challenges, decoys and slows are never sampled: they are the security record and are counted at face value.
Everything else — rules, blocklists, allowlists, geo policy, custom WAF rules — lives in the dashboard and arrives through the sync described below. Nothing about detection is configured here. The scoring weights and tier thresholds below are compiled into the agent and are not configurable from either side.
In order, and the order is the point:
- Scope filter. Non-HTTP scopes and paths excluded by
only_paths/except_paths/only_regexpass straight through. - Challenge return. A request carrying
?up_token=is verified as a v2 pass token. Valid, and the agent mints its own passport cookie and redirects to the same URL with the token stripped. Invalid, and it is a403— the only way to hold a valid token is to have just passed the challenge, so a broken one is an attempt, not an accident. - Passport. A valid
relintio_passportcookie short-circuits to allow, verified locally with no network call. - Policy. Rules are fetched from
/agent/verify, AES-256-CBC encrypted under a key derived from the licence key and authenticated with HMAC before decryption. They are cached in memory, and on disk beside an HMAC sidecar — a cache whose MAC does not verify is deleted and re-fetched rather than trusted, because shared hosting means the file is not yours alone. - Server-side exclusions.
bypass_pathsandcurl_safety_pathsfrom the policy, then TLS fingerprint checks, the IP allowlist, and SEO safety. - Intelligence. Global blocklist, geo firewall, blocked CIDRs, honeypot headers, scanner user agents and bot regexes, VPN and proxy heuristics over reverse DNS, referrer checks, then your custom WAF rules.
- Deep scan. The additive score below, and the tier it lands in.
- Allow, reported at the sample rate — one in a hundred.
Refresh happens in the background on a jittered interval — sync_interval_seconds multiplied by 0.8–1.2 — and backs off exponentially to five minutes after repeated failures, so a control plane recovering from an outage is not re-hammered by every agent at once.
Signals are independent and additive, capped at 100:
| Signal | Points | Why |
|---|---|---|
| Empty User-Agent | +50 | No browser omits it. |
| User-Agent under 10 characters | +25 | Too short to be a real one. |
| Scanner keyword in User-Agent | +40 | Matched against the keyword list from policy. |
| Rate burst | +35 | Token bucket exhausted. |
No Accept-Language |
+20 | Every browser sends one. |
Missing or */* Accept |
+15 | Generic client. |
POST without Referer |
+15 | Form submission from nowhere. |
Connection: close |
+10 | Not how browsers behave. |
| Tier | Score | What happens |
|---|---|---|
| Allow | 0–39 | The request proceeds. |
| Slow | 40–59 | Two-second asyncio.sleep, then proceeds. |
| Challenge | 60–74 | Redirect to the hosted challenge. |
| Decoy | 75–84 | A maintenance page, served 200. |
| Block | 85–100 | The configured block response. |
The rate limiter is a per-IP token bucket: 8 tokens per second, burst 24, with route multipliers that widen it for /assets/ and tighten it for /login, /auth and /wp-admin. Buckets idle for two minutes are evicted every five.
Deep scan is skipped for paths the policy marks curl-safe, so an API a customer calls with curl is not blocked for looking like curl.
Both mechanisms are specified in contracts/agent-protocol-v2.md, and this SDK is checked against the shared vectors in contracts/passport-v2-vectors.json. Neither needs the network at verification time, because the edge has to keep deciding when the control plane is unreachable.
A passport is v2.<payload>.<signature> — HMAC-SHA256 over the encoded payload bytes, keyed on the licence key. It expires on its own, and it is bound to the visitor's raw User-Agent and Accept-Language headers, so a stolen cookie is worth nothing on another client. The binding uses the uncapped header values: the agent truncates the user agent to 1024 characters for logging, and using that truncated value here would make this agent disagree with the challenge server and lock out every visitor with a long user agent. The IP is deliberately not part of the binding, because the challenge and the protected site routinely observe different client addresses. The cookie is HttpOnly, SameSite=Lax, Secure over HTTPS, with its lifetime clamped to between 5 minutes and 7 days.
Signing covers the exact transmitted bytes. Every outbound call — /agent/verify, /agent/log, /agent/heartbeat, /agent/challenge/init, /agent/geo-lookup — goes through signed_json_request(), which serialises the body once and returns it alongside the headers that sign it:
from ultimateprotector_agent.client import signed_json_request
body, headers = signed_json_request({"license_key": "UP_LIVE_...", "ip": "203.0.113.7"}, "UP_LIVE_...")
# httpx: content=body, never json=payloadReturning both from one call is the whole design. The server hashes the bytes it receives, so anything that re-encodes the payload after signing produces a signature it cannot reproduce — and httpx's separators and ensure_ascii differ from ours, so a body carrying a non-ASCII user agent would go out in a form the signature does not cover. Serialise once, hash those bytes, send those bytes. A new endpoint added around this helper rather than through it will not degrade gracefully; it will take a 401 and go quiet.
Signatures are rejected outside ±300 seconds, so keep the server's clock in step with NTP.
challenge_enabled is a policy setting and it is also plan-gated: a licence without the bot challenge has it forced off server-side.
Rather than have each agent read the flag, /agent/challenge/init refuses to issue a token when it is off and answers 200 with {"status": "challenge_disabled", "fallback": "allow"|"block"}. The 200 is deliberate — this is a policy answer, not an outage, and an agent that treated it as a failure would fail closed on a setting the customer turned off on purpose.
This agent does call that endpoint, and _start_challenge() reads fallback off the answer. allow is the only value that clears it, so a fallback that is missing or that this agent does not recognise blocks — the platform's own default, and the safe reading of a field it could not parse. _respond_challenge() returns False on allow and the request carries on down the middleware to your application; on block it serves the block page and returns True. Every caller branches on that return, so the two cannot diverge.
Do not confuse this with a failed call. challenge_disabled is a policy answer carrying what to do instead; a timeout, a refused connection or an unreadable body is unavailable, which has no fallback to honour and always blocks. _start_challenge() keeps the two apart, and so must anything built on top of it.
An unreachable control plane fails open. A timed-out sync leaves the last known rules in place, or lets the request through when there are none, and schedules a retry.
An answer of expired or outdated does not. Both put the agent into its expired state, and in that state it stops evaluating and serves a 503 notice page that reloads every six seconds — for every visitor, until the licence is restored. That is protection ending loudly rather than silently, but it is a hard failure and worth knowing before you deploy.
Those two are the whole list. Going over an allowance is no longer one of them: quota_exceeded used to be a third state that dropped the ruleset and stood the agent down over a billing number, and it is gone from the platform. Overage warns and bills. Anything else the control plane can answer with — an unknown status, an empty body, a payload that will not decrypt — is a failed sync: the last good policy stays in force and the agent retries with backoff.
A challenge that cannot be initialised becomes a block. /agent/challenge/init is signed like everything else; if it fails, the agent serves the block page rather than putting the licence key in a redirect URL where it would land in access logs and referrer headers. No challenge was issued, so nothing was passed, and the score that sent this visitor here still stands — the whole fleet agrees on this, and an unreachable token service is not the way in. See Challenge disabled above for the case where the customer turned the challenge off on purpose, which is not this one.
Geo is resolved server-side when the CDN did not. With no CF-IPCountry or equivalent, the agent calls /agent/geo-lookup, which reads local MaxMind GeoLite2 data. Results are cached for 24 hours, concurrent lookups for the same address are deduplicated, and a failed lookup yields XX rather than an error.
Geo blocks are soft. A visitor refused by the geo firewall gets a plain regional-policy page and no ban — they are not an attacker, they are in the wrong country.
Logging is fire-and-forget. Log calls are background tasks whose responses are never inspected, so nothing local notices if the ingest endpoint rejects them. A logging outage is invisible from inside the process; check the dashboard, not the application log.
Heartbeat is a background thread, at most one every five minutes, taken off the event loop with a two-second timeout, and swallowing all errors.
The distribution is 0.9.8; the agent reports 0.9.6 as agent_version and in X-Agent-Version, which is what the dashboard displays. The two drifted and the reported value is the one that matters when someone asks you which version is deployed.
relintio_agent is the documented package. ultimateprotector_agent is the original name and remains a working alias — relintio_agent re-exports from it — so existing imports keep resolving. New code should use relintio_agent; internals not re-exported, such as signed_json_request, still live under the old name.
Start in observe mode. Watch a day of real traffic in the dashboard, confirm the requests you expect are scored the way you expect, and only then enforce. Never carve out login, registration, checkout or password reset to silence a false positive — those are the routes the agent exists for.
An install that has not been verified against the control plane is not an install: restart, open one public route the middleware handles, then enter that URL in Relintio and select Verify target.
Proprietary. See LICENSE.