cookieless analytics: how a rotating daily salt kills the consent banner
identify visitors with a rotating sha-256 hash instead of a cookie — and skip the consent banner by never holding personal data
every week i click “reject all” maybe forty times. cookie wall, reject all. cookie wall, manage preferences, uncheck the eight hundred “legitimate interest” partners, confirm. the banner that buries its reject button two clicks deep because the law says both choices must be equally easy and everyone pretends theirs is. it’s the loading screen of the modern web, and almost nobody consents to anything — they just want the thing gone.
here’s the part that took me embarrassingly long to internalise: most of those banners exist to prop up analytics that never needed your personal data in the first place.
i’m building a privacy-first analytics tool — the kind of thing you drop on a site to learn how many people read a page, where they came from, whether the new landing copy converts. the design question that shaped the entire ingestion path was this: can i tell a returning visitor apart from a new one, inside a single session, without a cookie and without ever storing anything that counts as personal data?
if the answer is yes, the consent banner has nothing left to protect. it can die.
the trick is a hash and a salt that rots every twenty-four hours. this is the plausible ↗ and fathom position, and after building it myself i’m convinced it’s the correct default for first-party analytics. here’s how it works — and, because the rule around here is i don’t get to pretend, exactly where it stops being anonymisation and becomes a lawyer’s problem.
the banner is a tax on a decision you already made
the cookie banner is not, strictly, a gdpr thing. it’s an eprivacy thing. the consent you keep clicking through exists because someone wants to store or read information on your device — that’s the trigger in article 5(3) of the eprivacy directive, transposed in spain as article 22 of the lssi. a cookie is stored state on your machine; reading it back is access to your device; that access needs consent. the banner is the price of the cookie.
so remove the cookie. store nothing on the visitor’s device — no cookie, no localStorage, no fingerprint stashed anywhere — and the eprivacy trigger is simply never pulled. there is no stored state to ask about.
that’s only half the problem, because the server still processes something about each visitor, and gdpr governs that half. article 5 ↗ sets the principles: data minimisation — data must be “adequate, relevant and limited to what is necessary in relation to the purposes” — and storage limitation — kept “no longer than is necessary”. the whole game is to process the least you can, hold it the shortest time you can, and make sure what you do keep can’t be pinned back to a human.
which brings us to the hash.
the identity model: a hash, not a cookie
a cookie is a durable name you staple to a browser so you can recognise it next tuesday. i don’t want a durable name. i want a disposable one — good enough to group today’s pageviews into sessions, useless tomorrow. so instead of assigning an identity, i compute one, on every single request, from things the browser already sends:
core/src/ingestion/privacy.rs
/// Compute a privacy-preserving visitor hash.
///
/// This is the core of GDPR compliance: we never store the raw IP or
/// user-agent. Instead we combine them with a daily-rotating salt and
/// hash the result. When the salt rotates, all previous visitor
/// identities become unlinkable.
#[must_use]
pub fn compute_visitor_hash(ip: &str, user_agent: &str, daily_salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(daily_salt.as_bytes());
hasher.update(ip.as_bytes());
hasher.update(user_agent.as_bytes());
hex::encode(hasher.finalize())
}that’s the whole identity model. sha-256(daily_salt + ip + user_agent), hex-encoded, sixty-four characters. no cookie is set, nothing is written to the visitor’s browser, and the ip and user-agent walk in, get folded into a digest, and are never seen again (more on that in a moment).
the salt goes in first, which matters for the same reason the previous-hash goes first in my verifactu chain : it makes the whole output a function of the salt. change the salt and every hash changes, wholesale. that’s the lever the next section pulls.
the salt is the whole trick, and it rots on purpose
a hash of ip + user_agent alone would be a permanent identifier — the same laptop on the same network hashes to the same value forever, and that is exactly the durable name i was trying to avoid. the salt is what turns a permanent identifier into a daily one. it’s a random 32-byte value, and it expires:
core/src/db/sqlite.rs
pub fn get_or_create_today_salt(
&self,
salt_rotation_hours: u32,
) -> Result<String, AppError> {
if let Some(existing) = self.get_current_salt()? {
return Ok(existing.salt);
}
// Generate a new random 32-byte hex salt
let salt_bytes: [u8; 32] = rand::rng().random();
let salt_hex = hex::encode(salt_bytes);
let expires_at =
chrono::Utc::now() + chrono::Duration::hours(i64::from(salt_rotation_hours));
let expires_str = expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.create_salt(&salt_hex, &expires_str)?;
Ok(salt_hex)
}get_current_salt only ever returns a row whose expires_at is still in the future, so once the clock passes the expiry the old salt is dead to the hashing path. a background task keeps a fresh one alive:
server/src/main.rs
tokio::spawn(async move {
loop {
// Sleep until the salt rotation interval elapses
tokio::time::sleep(Duration::from_secs(u64::from(salt_hours) * 3600)).await;
let db = sqlite_for_salt.lock().await;
match db.get_or_create_today_salt(salt_hours) {
Ok(_) => info!("daily_salt_rotated"),
Err(e) => warn!(error = %e, "salt_rotation_failed"),
}
}
});default rotation is twenty-four hours. so today, your browser is a3f9…; tomorrow, with a brand-new random salt, the identical browser on the identical network is 7c21…, and there is no key on earth that links the two — the old salt is random, expired, and (see the honest section) i can’t reconstruct it. the daily identifiers are unlinkable across days by construction. that single property is what lets me claim this isn’t durable tracking.
and it’s testable, which is the version of a claim i trust:
core/src/ingestion/privacy.rs
#[test]
fn different_salt_different_hash() {
let h1 = compute_visitor_hash("192.168.1.1", "Mozilla/5.0", "salt-day-1");
let h2 = compute_visitor_hash("192.168.1.1", "Mozilla/5.0", "salt-day-2");
assert_ne!(h1, h2);
}same visitor, two days, two salts, two hashes that share nothing. the test is asserting the privacy property, not the plumbing.
raw ip and user-agent never touch the disk
the docstring up top makes a promise — “we never store the raw ip or user-agent” — and a promise in a comment is worth nothing unless the code path backs it. here’s the ingestion route where it has to hold:
server/src/routes/ingest.rs
// 4. Extract IP and User-Agent from headers
let ip = extract_client_ip(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// 5. Get daily salt from SQLite
let salt = { /* ... fetch current non-expired salt ... */ };
// 6. Compute visitor_hash = SHA-256(IP + UA + salt)
let visitor_hash = privacy::compute_visitor_hash(&ip, user_agent, &salt);
// 7. Track session -> session_hash
let session_hash = {
let mut sessions = state.sessions.lock().await;
sessions.track(&visitor_hash)
};the ip and the user-agent exist as local variables for the length of one function call. they feed the hash, they feed geoip (country only — never the address), and then the stack frame unwinds and they’re gone. the thing that gets buffered and written to the analytics store is visitor_hash and session_hash — two opaque digests. there is no INSERT anywhere in this codebase that puts a raw ip into a column, because there’s no column to put it in. you can’t leak what you never persisted.
the client side matches. the tracking snippet sets no cookie and reads no cookie; it just fires a beacon:
snippet/analytics.js
function s(t,u,g,p){
if(ex(new URL(u).pathname))return;
var b={t:t,u:u,r:d.referrer||null,w:w.innerWidth};
if(g)b.g=g;if(p)b.p=p;
var j=JSON.stringify(b);
if(navigator.sendBeacon)navigator.sendBeacon(api+"/api/event",j);
else{var x=new XMLHttpRequest();x.open("POST",api+"/api/event",true);x.setRequestHeader("Content-Type","application/json");x.send(j);}
}no document.cookie, no storage, nothing durable left behind on the device. the eprivacy trigger is never touched.
sessions live in memory and forget by design
“was this pageview part of the same visit as the last one?” needs a tiny bit of short-term state, and this is the one place i keep any. it lives in a HashMap, in memory, and it’s keyed by the hash — never by anything raw:
core/src/session.rs
pub struct SessionTracker {
sessions: HashMap<String, SessionEntry>,
timeout: Duration,
counter: u64,
}
pub fn track(&mut self, visitor_hash: &str) -> String {
let now = Instant::now();
let existing_hash = self.sessions.get(visitor_hash).and_then(|entry| {
if now.duration_since(entry.last_seen) < self.timeout {
Some(entry.session_hash.clone())
} else {
None
}
});
let hash = existing_hash.unwrap_or_else(|| {
// New session: use monotonic counter for uniqueness
self.counter += 1;
let input = format!("{visitor_hash}:session:{}", self.counter);
compute_session_hash(&input)
});
self.sessions.insert(/* visitor_hash -> { now, hash } */);
hash
}seen this hash inside the last thirty minutes? same session. otherwise, mint a new one. the map would grow forever, so a second background task sweeps it every five minutes:
core/src/session.rs
pub fn cleanup_expired(&mut self) {
let now = Instant::now();
self.sessions
.retain(|_, entry| now.duration_since(entry.last_seen) < self.timeout);
}thirty-minute idle windows evaporate. the state is deliberately amnesiac: it exists to answer one question about the last half hour and then throws itself away.
the small stuff that adds up
two more knobs, both defaulting to the private setting. query strings get stripped before anything is stored, so a ?token=… or ?email=… that some other system stapled onto a url never lands in my database — only the path survives (utm campaign tags are pulled out into their own fields first):
core/src/ingestion/enrich.rs
fn clean_url_path(url: &Url, strip_query_params: bool) -> String {
if strip_query_params {
url.path().to_owned()
} else {
let path = url.path().to_owned();
match url.query() {
Some(q) => format!("{path}?{q}"),
None => path,
}
}
}there’s also a respect_dnt flag in my config that defaults to true — and i’ll be honest in the next section about how much that’s currently worth.
is this legal? pseudonymisation is not anonymisation
now the uncomfortable part, because i refuse to sell this as more than it is.
the daily hash is not anonymous data. it’s pseudonymous data, and gdpr is explicit that those are different animals. article 4(5) ↗ defines pseudonymisation as processing “in such a manner that the personal data can no longer be attributed to a specific data subject without the use of additional information” — and recital 26 ↗ drives the nail home: pseudonymised data “which could be attributed to a natural person by the use of additional information should be considered to be information on an identifiable natural person.” within a single day, my hash is exactly that. the salt is the additional information; hold the salt plus a candidate ip and user-agent and you can recompute the hash. same-day requests are linkable by design — that’s not a bug, it’s the mechanism sessions run on.
so gdpr does apply to the hash while it’s live. what recital 26 also says, though, is that anonymisation is judged by “all the means reasonably likely to be used” to re-identify — costs, time, available technology. once the salt rotates and is gone, yesterday’s hashes have no reasonably-likely path back to a person. within-day: pseudonymous personal data. across-day: as close to anonymous as this gets.
here’s why the banner still dies. the consent banner is the eprivacy/cookie trigger, and i pull no cookie, so that’s settled on its own terms. the server-side processing of a pseudonymous hash for first-party, aggregate, no-cross-site-tracking analytics is the textbook candidate for legitimate interest — not consent. spain’s regulator, the aepd, treats first-party audience-measurement narrowly and strictly in its cookie guidance ↗ , and the direction of travel across the eu is that genuinely privacy-preserving, first-party measurement doesn’t need a consent wall.
that is a defensible reading. it is not settled law. “defensible” is doing real work in that sentence. i’m an engineer typing in lowercase, not your dpo, and the honest instruction is: this design gives your data protection officer a strong position to sign off on skipping the banner — it does not replace the review. get the review.
the honest part: what this doesn’t do
- in-memory sessions die on restart. the
HashMapis process memory. deploy, crash, or reboot and every live session resets — a visitor mid-visit silently starts a new one. i lose a little session-stitching accuracy across a deploy and i’ve decided that’s fine. it would not be fine for a billing system. - it does not survive more than one instance. two processes behind a load balancer each keep their own map, so the same visitor hitting instance a then instance b gets two sessions. this design quietly assumes a single node. horizontal scaling needs a shared store (redis, or sticky routing), and that’s a rewrite of this module, not a config flag.
- i rotate the salt but i don’t delete it. plausible deletes the old salt every twenty-four hours; mine merely expires — the row lingers in sqlite. an expired-but-retained salt is a re-identification key sitting in my own database, which weakens the “reasonably likely means” argument above more than i’d like. deleting expired salts is the single highest-value thing on this list and it’s embarrassing that it’s a list item and not a line of code.
- the salt rotates on a boot-relative timer, not a calendar boundary. the task sleeps twenty-four hours from process start, so “a day” drifts with every restart instead of snapping to midnight utc. harmless, but not what the comment implies.
respect_dntcurrently does nothing. the flag is in the config struct and defaults totrue, and then no code reads it. do-not-track is a functionally dead browser signal anyway, but a toggle that lies about its own behaviour is worse than no toggle. it’s either getting wired to global privacy control or getting deleted.
none of these are “it crashes” bugs. they’re the honest distance between a nice privacy property and a finished, scale-ready feature.
lessons learned
- the least-data design is also the least-liability design. i didn’t set out to dodge a banner; i set out to store as little as possible, and the banner fell out for free. minimisation isn’t a compliance chore you bolt on — done first, it deletes whole categories of problem before they exist.
- make the privacy property a test, not a comment.
different_salt_different_hashasserts unlinkability in three lines. a docstring that promises “we never store the raw ip” is a hope; the test that fails the day someone adds anipcolumn is a guarantee. - name the gap between pseudonymous and anonymous out loud. the most dangerous move i could make is to believe my own marketing and call this “anonymous”. it isn’t, for a day at a time, and pretending otherwise is how you get a nasty surprise from a regulator who read recital 26 more carefully than you did.
this is the same instinct as the verifactu hash chain : compliance is an engineering property you build into the domain, not a banner you paste over the top. and it’s another point for the rust half of my split stack — this is exactly the kind of hot-path, zero-copy, delete-the-data-before-it-lands work i want a borrow checker watching over.
next: the ingestion buffer that batches these events into columnar storage without ever blocking the request — and why the answer to “how do i not store personal data” turned out to be “store almost nothing, very fast.”
the rest of my work is on github ↗ if you want to poke around. this one’s still a work in progress, like everything here.