Identifying without retaining IP addresses
Turned on per organization in Settings → IP address handling.
With it on, the visitor's address is never written down. It is used for the
length of the request to derive a keyed digest, and then it is gone: the event
stores the digest, every response reports 0.0.0.0, and no lookup is made
against any third party — so the address does not leave the process either.
What was stored before it was turned on is the address, and the setting is a
promise about that too: from the moment it is on, no response returns one — an
event's ip and products.ip_info.ip read 0.0.0.0, a visitor's lastIp is
empty — and the retention sweep (hourly, or /api/cron) rewrites the stored
rows, a batch at a time, so each event keeps the address's digest instead
(velocity still counts the same source as one) and each device forgets its last
address; what its webhooks were sent, kept as sent for a replay (30 days at
most), is rewritten as the event is, so a replay sends no address. The
addresses velocity counted in the day before — a day's sightings,
kept in the short-lived store — expire within 25 hours. What was concluded from an address then — its country, its network —
stays with the event it was concluded for.
Why a keyed digest and not a hash
IPv4 is 32 bits. A plain SHA-256 of every address on the internet is about four billion digests: minutes of work on a laptop. A bare hash of an IP is not pseudonymous, it is an encoding.
The digest is an HMAC under a key the dataset does not contain, which is what makes the transformation one-way in practice rather than only in appearance. The organization id is mixed in too, so one address yields different digests per tenant and a leaked digest cannot be joined across them.
Set
IP_PSEUDONYM_KEYin production. Without it a key is generated and stored in the database — which means a reader holding the whole database also holds the key. The guarantee is only ever as strong as the key's separation from the data it protects.
What it costs
| Under pseudonymization | |
|---|---|
| Geolocation, VPN, proxy, Tor, IP blocklist | unavailable |
| Visitor ID, all browser and device signals | unchanged |
| Velocity | works |
| Request filtering by IP and CIDR | works |
| The browser's distance against its connection (first-party proxy) | works — the edge binds its pings to the address with a keyed tag it alone can make, and the API is told only the timings |
Signals that cannot be computed report unavailable: true with a reason, not
result: false — the second would be a finding nobody made.
const vpn = event.products.vpn
if (vpn.unavailable) {
// No opinion. Do not read this as "not a VPN".
} else if (vpn.result) {
// An actual detection.
}
Two things that survive, and why
Both are deliberate departures from how this feature is usually documented, and both are cases where the privacy goal does not require giving the capability up.
Velocity survives. It only ever asked whether two requests came from the same source, never which one, and the digest answers that exactly as well as the address. Discarding it would give up real signal for no privacy gain. The same holds for the velocity of assessed sign-ins, payments and texted codes: each row pairs a keyed digest of the address with a keyed digest of the account, of the inbox an email is delivered to, of the card identifier, or of the phone number and its block of a thousand — never any of them as sent — and is swept after an hour. A redeemed code is kept shorter still: 24 bits of its keyed digest — for a 16-digit gift card number, hundreds of candidates behind each; a short all-digit code is narrowed to a few, which is why the guide asks for your own keyed hash of codes like that. A posted message is never kept as text: for two hours, as a keyed MinHash — 24 numbers that say how much two messages share, and nothing of what either said; a moderator's verdict on one, the same way, for seven or thirty days.
One thing is kept longer: where an account has signed in successfully
before, so that a sign-in from a device or a country it never used can be told
apart (velocity.newDevice, velocity.newCountry). It is a keyed digest of the
account beside a keyed digest of the device (the visitor id of the receipt the
sign-in carried) or a country code, written only when your application says a
sign-in succeeded — never on a success an edge read from a status code — and swept 180 days after the last such success. Under
pseudonymization the country is never known, so only the device half is kept.
Request filtering survives. Filtering runs before anything is stored, on the address the transport already carried, so blocking a CIDR range costs no retention at all.
Network reputation does not survive. It works by recognising one address across organizations, which is exactly what pseudonymization exists to prevent: with it on, the organization neither contributes to network reputation nor reads it.
The WebRTC leak survives. It compares the address the browser's UDP path came
from with the request's, in memory, and keeps only whether they were in the same
network — with or without pseudonymization, neither address is stored or looked
up for it. Note what the check itself discloses: the browser contacts a STUN
server (Cloudflare's and Google's by default), which sees the visitor's address.
Point webrtc.stun at your own server — the forward-auth
service answers STUN itself with ANTIBOT_STUN_PORT set — or
set webrtc: false, if that is not acceptable.
Turning it back off
Does not resurrect anything. What was never written cannot be recovered.
Events created while it was on keep their digest and keep reporting 0.0.0.0
forever. Only new events carry addresses again.
What a page's address keeps
The agent reports the page it ran on and its referrer as the browser gives
them, query and fragment and all. Before anything reads them — the event, the
rules, the webhooks, the dashboard — each value that is a secret or a person's
is written redacted: a parameter whose name says token, code, key, password,
secret, signature, session, ticket and the like (?token=, ?code=,
#access_token=, ?api_key=, and run together, ?sessionid=), any value
that is an email address or a JSON Web Token whatever its name, a value that
carries an address or parameters of its own holding one (a return address,
?next=/reset?token=…, encoded or not), and a query that is a token alone
(?9f8a7b6c5d4e3f2a1b0c). The parameter's name stays, so a rule can still ask
whether a page was reached with one, and an address with none is kept byte for
byte. The edge's assessments keep a request's path alone, and log streams the
host and path, never the query.
How long identifications are kept
Until you delete them, unless you set a retention. eventRetentionDays on the
organization (PATCH /api/management/v1/organization, 1 to 3650 days, null
to keep until deleted) deletes, every hour:
- events older than that;
- visitors not seen since — a visitor's record is its device's components and last address, and its last-seen time moves with every event, so one unseen that long has no event left either — and high-recall visitors likewise;
- webhook deliveries older than that.
Webhook deliveries, which carry the event they delivered, are a delivery log rather than a record: they are kept 30 days whatever the setting, or less with a shorter retention. A delivery still waiting to be retried is never deleted — unless its retry is a day overdue, which nothing will make.
A few more things are kept only while they are of use, whatever the setting: a
waiting room's spots — each carries the visitor's id and a keyed digest of
their address, where the room binds addresses — 7 days after they expired and 30 after they were admitted (a
visitor keeps their place through an event, not into the next); the
geolocation answers cached per address, 30 days after they were last fetched;
the links mailed to an account to confirm its address or reset its password, 7
days after they were used or lapsed; and invitations, 30 days after they were
accepted, revoked or lapsed — each of these two holds the address it went to.
Local IP databases (ANTIBOT_IP_MMDB) cache nothing, and neither do the public
blocklists (ANTIBOT_BLOCKLISTS_DIR, or fetched by the service with
ANTIBOT_BLOCKLISTS=fetch and kept in its database as published): both are
files on your server that an address is looked up in, in memory, and sent
nowhere.
The audit log is kept as long as the organization: every change made in the dashboard or through the Management API, by whom — the member's address, or the key's name — and from which IP address. No retention setting reaches it, and deleting the organization deletes it.
Shortening the retention deletes history at the next sweep, and cannot be
undone; the change is in the audit log. A device's typing and pointer
statistics go with its visitor, and with a visitor deleted through the API.
The sweep deletes in batches for up to 20 seconds at a time (25 from
/api/cron); an environment it
has not caught up with is swept again every five minutes until it has. The time
is shared: each organization gets its share of what is left when its turn
comes, the turn order moves on every sweep, and the delivery log has a quarter
to itself — one organization's years of backlog delay no one else's deletions
by more than a sweep. The sweep runs on a timer in every long-lived instance;
on a platform whose instances end with their requests, call
/api/cron on a schedule.
The dashboard's counts are kept for 30 days whatever the setting: per environment and hour, how many identifications and assessments there were, how many of them each signal flagged, and how many came from each browser, system, device type and country. The day's totals that usage and billing read — identifications, bots, blocks, proxied, visitors seen, and the counts per platform — are kept 400 days. They are numbers under labels; none names a visitor, an address or an event, and deleting a visitor or an event leaves them as they were.
A refusal is kept for seven days whatever the setting, with the address, user
agent, host and path it was about, so the reference a visitor quotes finds it;
an allow is not kept at all. Under IP pseudonymization no address is kept
there at all. Counters that only ever held digests
for minutes or hours (accounts, cards, codes, receipts tried) expire on their
own clocks regardless. Deleting one visitor or event now is the Server API's
DELETE /api/v4/visitors/{visitorId} or DELETE /api/v4/events/{requestId} —
each with what the environment's webhooks were sent of it, which is otherwise
kept as sent for a replay, and the let-through rule the dashboard wrote for the
visitor's device from one of its refusals (a rule you wrote yourself that
names it is yours, and stays). Each erasure is in the organization's audit log
(visitor.erased, event.erased): the id, when, and the key that made it —
the record of a request carried out. The id is a random one, and names nothing
once its rows are gone.
What is erased stays erased
An account deleted, or an organization, goes with everything that is its own; so does a visitor or an event erased through the Server API — how you answer a person's request to be forgotten — and an environment's identification data purged. Backups are kept 14 days, and a database restored to before an erasure would have it all again, with nothing left to say it had been erased: an organization's audit log goes with it. So an erasure leaves its id behind, and nothing else: no name, no address, no content. A restore copies the ids of what was erased since its point into the restored database, and the first retention sweep, a minute after the service starts, erases each again the way it was erased the first time. A device that comes back after its visitor was erased is a new visitor, and is not; a purged environment is purged again only of what it held before the purge. The ids are kept 35 days — longer than any backup — and then forgotten.