Cookieless Analytics: How to Track Users Without Cookies
Cookieless analytics explained: compare daily rotating hashes, localStorage and server-side sessions, with runnable Node.js code and honest accuracy limits.
A cookie banner shrinks your measured traffic, because every visitor who declines or ignores it disappears from your numbers. That is the commercial pressure behind cookieless analytics: you still want to know how many people visited, which pages they read and where they came from, but you do not want to store an identifier on their device to learn it.
The honest version of this topic is a trade. Every technique that avoids a persistent identifier gives up something, whether accuracy, cross-day continuity or legal simplicity. If someone promises cookieless tracking with no loss and no legal question, they are selling you something.
This article compares the three techniques that work in practice for Acme Shop: a daily-rotating salted hash, a random identifier in localStorage, and server-side sessions. You get runnable Node.js code for the first and third, a comparison table, and a clear account of what each one miscounts. The basics of what a cookie is live in the first article on how web analytics works, so I do not repeat them here.
One boundary matters before we start. This article does not describe fingerprinting that tries to recognise people across days or sites without their consent. I will show why that is a different thing, and I will not build it.
What Cookieless Analytics Actually Means
The word is misleading. Cookieless means you do not store a persistent identifier in a cookie. It does not mean you do not identify anyone, and it does not mean the law stops applying. Three separate questions hide inside it. Note that the tracker from the click-tracking article stores anonymous_id in a first-party cookie, which the techniques below replace. Events that arrive with no identifier at all, such as rows rebuilt from server logs, count as unattributed, not as one visitor.
- Where does the identifier live? In the browser (cookie,
localStorage), or only on your server. - How long does it live? One request, one session, one day, or forever.
- What can it link? Page views within a visit, visits within a day, or visits across weeks.
A good design answers all three with the smallest possible value. If your report needs only daily unique visitors and page popularity, you do not need an identifier that lives for a year. Match the identifier’s lifetime to the question your dashboard answers.
Recall the event shape used across this series. Each event carries anonymous_id and session_id. Cookieless analytics changes who creates those two values. In the cookie model, the browser creates them and stores them. In the cookieless model, the server computes them, and the browser sends neither. The columns in the events table stay exactly the same, so this article extends nothing in the schema. If you use the tracker from the earlier articles, delete its anonymous_id and session_id lines, or leave them and let the server overwrite them.
Technique 1: The Daily-Rotating Salted Hash
The idea is to build a visitor identifier from information that every HTTP request already contains. The server reads the client IP address, the User-Agent header and the site hostname, mixes them with a secret salt, hashes the result, and uses the hash as anonymous_id. It then discards the raw inputs. Plausible, an open-source analytics product, documents an approach in this family, and the design is common in privacy-focused tools.
The key property is the salt. It is random, it changes every 24 hours, and the server deletes the old one. After rotation, nobody can recompute yesterday’s hash for a given visitor, not even you. Within one day, the same visitor produces the same hash, so you can count unique visitors and group page views into sessions.
The wrong way: a static salt
// WRONG: the same salt forever
import { createHash } from 'node:crypto';
function visitorId(ip, ua) {
return createHash('sha256').update('acme-secret' + ip + ua).digest('hex');
}
This is a persistent identifier wearing a disguise. The value never changes, so it follows a person across weeks, and it behaves exactly like a long-lived cookie that the user cannot delete. Worse, the input space is tiny. There are only about four billion IPv4 addresses, so anyone who gets the hash and the salt can enumerate every address and recover the original. A plain hash of an IP is pseudonymisation at best.
The right way: a random daily salt
The fix has three parts. Use a keyed hash (HMAC) rather than string concatenation. Generate the salt randomly instead of deriving it from a date. Keep it only in memory, and replace it at the UTC day boundary. The Node.js crypto module provides createHmac and randomBytes for exactly this.
// visitor-id.mjs
import { createHmac, randomBytes } from 'node:crypto';
let saltDay = null;
let salt = null;
function currentSalt() {
const today = new Date().toISOString().slice(0, 10); // UTC day
if (today !== saltDay) {
salt = randomBytes(32); // new random salt, never written to disk
saltDay = today;
}
return salt;
}
export function visitorId({ hostname, ip, userAgent }) {
return createHmac('sha256', currentSalt())
.update(`${hostname}|${ip}|${userAgent}`)
.digest('hex')
.slice(0, 32);
}
The delta is large. The identifier is now valid for one UTC day, the raw IP is never stored, and the salt cannot be recovered from the database or from a backup. The trade-off appears at restart: a deploy mid-day creates a new salt, so a returning visitor is counted twice that day. The same happens at midnight UTC, which splits any session that crosses the boundary. Multiple server instances also need to share the salt, or each instance computes a different identifier for the same person. A small table that holds today’s salt and is purged daily solves that, at the cost of storing a secret. Decide whether that fits your threat model.
What the hash can and cannot do
It can count daily unique visitors and tie requests into a visit. It cannot tell you whether a visitor returned yesterday, so weekly retention for anonymous traffic is impossible by design. That is the privacy property, not a bug. For retention, you need an identifier that survives days, which means either consent-based storage or a logged-in user.
Accuracy suffers in both directions. Two people behind one office network with identical browsers share a hash and count as one. One person who moves from Wi-Fi to mobile data changes IP and counts as two. Browser updates change the user agent string, and a mobile carrier using carrier-grade NAT can place thousands of users behind one address. IPv6 adds a twist: devices often rotate addresses inside one network prefix, so consider hashing only the first 64 bits. Expect your unique-visitor figure to be an estimate with an error you cannot measure precisely.
Technique 2: A Random ID in localStorage
The second option stores a random value in the browser. You generate a UUID once, save it with localStorage.setItem, and send it with each event. Unlike a cookie, it is not attached to every HTTP request automatically, and ad-blocking rules that target cookies do not touch it.
It works better than the hash. The identifier survives IP changes, browser updates and shared networks, and it supports multi-day retention. For many teams that accuracy is the reason to choose it.
The catch is legal, not technical. Storing a value on a user’s device is the thing the ePrivacy rules regulate, and they are not limited to cookies. The European Data Protection Board’s Guidelines 2/2023 on the technical scope of Article 5(3) of the ePrivacy Directive explain that the rule covers other storage and access techniques too. Switching from a cookie to localStorage therefore does not by itself remove a consent question. Rename nothing and assume nothing; check with counsel.
Technically, the pattern is two lines in the tracker from the capture article:
const anonymousId = localStorage.getItem('aid') ?? crypto.randomUUID();
localStorage.setItem('aid', anonymousId);
Safari’s tracking prevention can cap how long script-written storage survives (check WebKit’s current documentation for the exact rules), and users clear storage often. So even this method undercounts returning visitors in some browsers. Use it when you obtain consent, or when the identifier is strictly needed to deliver a feature the user asked for. Then the longer lifetime is a legitimate benefit.
Technique 3: Server-Side Sessions
The third option keeps state on the server, not in the browser. For each request the server looks up a short-lived session keyed by something it can compute, such as the daily hash above, and tracks the last activity time. If the gap is under 30 minutes, the event joins the existing session. Otherwise the server starts a new one.
This approach pairs naturally with technique 1. The hash identifies the visitor for the day, and the server-side map assigns session_id using the same 30 minute inactivity rule as the rest of the series. The browser sends nothing identifying and stores nothing.
For logged-in users, you can skip the guesswork. After login, the application knows user_id from its own authenticated session, and the collector can attach it server-side. That session cookie belongs to your application’s login, not to analytics, so treat it as an application concern and have your legal advisor confirm how it is classified. Keeping analytics identity derived from the login on the server means the analytics script itself never touches storage.
The complete collector slice
Here is a runnable Express app that stamps cookieless identity on incoming events. It ignores any anonymous_id, session_id or user_id the browser sends, because a client-supplied value cannot be trusted. It prints the stamped event instead of inserting it. Validation, the CORS allow-list and rate limiting are left out to keep the slice short. Add them, and replace the console.log with the insert, from the collector article.
// cookieless-collector.mjs (npm install express)
import express from 'express';
import { randomUUID } from 'node:crypto';
import { visitorId } from './visitor-id.mjs';
const SESSION_GAP_MS = 30 * 60 * 1000;
const sessions = new Map(); // visitor hash -> { id, lastSeen }
function sessionFor(visitor, now) {
const s = sessions.get(visitor);
if (s && now - s.lastSeen < SESSION_GAP_MS) {
s.lastSeen = now;
return s.id;
}
const fresh = { id: randomUUID(), lastSeen: now };
sessions.set(visitor, fresh);
return fresh.id;
}
// drop expired sessions so the map cannot grow forever
setInterval(() => {
const cutoff = Date.now() - SESSION_GAP_MS;
for (const [key, s] of sessions) if (s.lastSeen < cutoff) sessions.delete(key);
}, 60_000).unref();
const app = express();
app.set('trust proxy', 1); // only if exactly one trusted proxy sits in front
app.use(express.json({ limit: '100kb', type: ['application/json', 'text/plain'] }));
app.post('/v1/events', (req, res) => {
const events = Array.isArray(req.body) ? req.body : [req.body];
if (events.length === 0 || events.length > 100) return res.sendStatus(400);
const now = Date.now();
for (const event of events) {
const anonymous_id = visitorId({
hostname: 'acme-shop.example',
ip: req.ip, // read, hashed, never stored
userAgent: req.get('user-agent') ?? '',
});
const stamped = {
...event,
anonymous_id,
session_id: sessionFor(anonymous_id, now),
user_id: null, // set from your login session, never from the client
user_agent: null, // do not store the raw string here
};
console.log(JSON.stringify(stamped)); // replace with the insert
}
res.sendStatus(202);
});
app.listen(3000, () => console.log('listening on :3000'));
Test it with curl twice from the same shell and you see the same anonymous_id and session_id both times. Change the User-Agent header and both values change. The raw IP address and the salt never appear in the output or the database.
Two production notes. First, trust proxy must match your real network layout. If you set it too loosely, any client can spoof X-Forwarded-For and pick its own identity. Second, the in-memory map is per process, so with several instances you need a shared store, or sticky routing, or you accept some split sessions.
A mistake I have seen in production was a team that logged the full request headers on the collector for debugging, “just for a week”. The raw IP addresses ended up in the log pipeline and in backups, which defeated the whole design. The fix was to remove header logging at the proxy, purge the log archives, and add a test that fails if an IP-shaped string appears in stored rows. Treat your logs as part of the privacy design.
Comparing the Three Techniques
| Property | Daily salted hash | localStorage ID | Server-side sessions |
|---|---|---|---|
| Stored on user’s device | No | Yes | No |
| Identifier lifetime | One UTC day | Until cleared, often long | 30 minute inactivity window |
| Cross-day retention | Not possible | Possible | Only for logged-in users |
| Main accuracy risk | Shared IPs merge, IP changes split | Cleared storage, browser limits | Restarts and multiple instances |
| Shared across subdomains | Yes, if hostname is normalised | No, storage is per origin | Yes |
| ePrivacy storage question | Lower, but ask counsel | High, treat like a cookie | Lower for the analytics part |
| Implementation effort | Low | Lowest | Medium |
Most teams combine them. The daily hash and server-side sessions run for everyone, and a consent banner gates an optional localStorage identifier for the visitors who say yes. The dashboard then shows an honest split between exact and estimated numbers.
Accuracy Limits You Must Show Your Users
Cookieless numbers differ from cookie-based numbers, and the gap should be understood before anyone compares them in a meeting. Four effects matter most.
- Unique visitors undercount. Shared networks merge people into one hash.
- Unique visitors overcount. People who switch networks, or whose user agent changes, become new visitors.
- Returning visitors are invisible. A daily hash cannot recognise yesterday’s visitor, so new versus returning splits are meaningless.
- Bots add noise. They share IP ranges and user agents and inflate or distort counts unless you filter them.
Page views, sessions and funnels within a single visit stay accurate, because they depend only on the same-day grouping. Daily pageviews, which feed the daily_pageviews rollup, are unaffected. Label unique-visitor figures as estimates in the dashboard, and never compare a cookieless number to an old cookie-based one without a note.
Where Fingerprinting Draws the Line
Browser fingerprinting combines signals such as canvas rendering, installed fonts, screen size and audio stack to produce an identifier that is stable across days and sometimes across sites. It needs no storage, which is why people call it cookieless. The purpose, however, is the opposite of what this article describes.
The daily-rotating hash is built to forget. Fingerprinting is built to remember, even when the user clears storage or declines tracking. Using it to evade a refusal of consent defeats the choice the user made, and regulators have said that the ePrivacy rules reach such techniques. I do not recommend it, and nothing in this series builds it.
A simple test helps. Ask whether your method could still recognise a person next week after they cleared everything. If yes, treat it as persistent tracking, and treat the visitor’s consent as required.
A Note on GDPR and ePrivacy (Not Legal Advice)
I am an engineer, not a lawyer, and this section is not legal advice. Rules differ by country and change over time, so confirm decisions with qualified counsel or your data protection officer.
Two European texts matter most. The General Data Protection Regulation (EU) 2016/679 governs personal data. Whether a salted hash counts as personal data depends on whether anyone could reasonably re-identify the person, and regulators do not all agree on that. The ePrivacy Directive 2002/58/EC, in Article 5(3), governs storing or accessing information on a user’s device. Because the daily hash stores nothing on the device, it may fall outside the storage rule. Regulators have not settled every case, though, and the GDPR question remains either way.
Practical hygiene goes beyond the law. Collect the minimum, never store raw IP addresses, document your retention period, and publish what you collect in plain language. Those habits help under any regulator.
How Real Systems Do This
Plausible computes a daily-changing visitor identifier on the server from request data and a rotating salt, and its documentation says it does not use cookies. Matomo can be configured to run without cookies, which limits what it can recognise between visits. Google Analytics 4 uses first-party cookies by default and offers consent-driven behaviour, so it sits at the other end of the spectrum.
The pattern across them is consistent: the less an identifier persists, the less the product can say about returning users, and the vendor markets that as a feature. Read each product’s own data policy, because the details of what is hashed and how long it lives differ and change.
Decision Framework
- What do you need to report? If only page views, visits and daily unique visitors, use the daily hash and stop there.
- Do you need multi-day retention or cohorts for anonymous visitors? If yes, you need a persistent identifier and a consent flow.
- Are most visitors logged in? If yes, attach
user_idfrom the application session and rely less on the hash. - Do you run several collector instances? If yes, share the daily salt and the session store, or accept inflated visitor counts.
- Who has reviewed the design? If nobody qualified has, do that before launch, not after the first complaint.
When NOT to Use This
- You need user-level journeys across weeks. A daily hash cannot give you that. Use consented identifiers, or require login for the features you want to analyse.
- Your audience sits behind a few shared IPs. Corporate or school networks with uniform browsers collapse into one visitor, so a hash badly undercounts. Choose another method or report page views only.
- You want a ready-made compliance posture. If legal certainty matters more than ownership of data, a hosted privacy-focused product with published legal documentation is a better purchase than a home-built hash.
Common Mistakes
- Using a static salt or a bare hash of the IP, which creates a permanent identifier and allows brute-force reversal.
- Persisting the daily salt in backups or config, so old hashes stay linkable.
- Trusting
X-Forwarded-Forwithout configuringtrust proxycorrectly, which lets clients forge their identity. - Logging raw headers or IPs on the collector, which silently rebuilds the data you meant not to keep.
- Treating
localStorageas exempt from consent, which ignores that ePrivacy covers device storage in general. - Comparing cookieless unique visitors to historical cookie-based figures without a caveat, which produces a false traffic drop.
Key Takeaways
- Cookieless means no persistent browser identifier. It does not mean no identification, and the law still applies.
- A daily-rotating HMAC of host, IP and user agent counts daily visitors without following anyone across days.
- Generate the salt randomly, keep it in memory or a short-lived store, and rotate it at the UTC day boundary.
- Switching from a cookie to
localStorageimproves accuracy but does not remove the consent question. - Stamp
anonymous_idandsession_idon the server and ignore client-supplied values. - Expect estimates for unique visitors, no returning-visitor split, and label the numbers honestly.
- Do not use fingerprinting to get around a refusal of consent, and get qualified legal review.
FAQ
How can you track users without cookies?
Compute an identifier on the server from request data, such as a keyed hash of IP address, user agent and hostname with a daily-rotating salt. Alternatively, keep short-lived sessions on the server, or derive identity from your own login. Each approach trades continuity for privacy.
Is cookieless analytics GDPR compliant?
Not automatically. Avoiding device storage helps with the ePrivacy rules, but a hashed identifier may still be personal data under the GDPR, depending on whether someone could re-identify the person. This is not legal advice, so consult qualified counsel and the primary texts.
Is localStorage tracking cookieless?
Technically it avoids cookies, but it still stores an identifier on the user’s device. The EDPB’s guidance on Article 5(3) of the ePrivacy Directive covers such storage, so treat it with the same care as a cookie.
How accurate is cookieless analytics?
Page views and same-day sessions stay accurate. Unique visitor counts become estimates, because shared IP addresses merge people and changing networks splits them. Returning visitors cannot be identified across days with a rotating hash.
Is browser fingerprinting the same as cookieless tracking?
No. Fingerprinting builds a stable identifier from device traits so it can recognise people across days, even after they clear storage. A rotating hash is designed to forget. Using fingerprinting to bypass consent is both ethically and legally risky.
Conclusion
Cookieless analytics works when you ask a smaller question: how many people visited today and what did they read, not who exactly came back. Pick the shortest identifier lifetime that answers your reports, compute it on the server, and tell your readers where the numbers are estimates. Honest cookieless analytics is a smaller promise kept, not a bigger promise hidden. The next article moves to the other end of the stack and builds a React dashboard on top of these stats.
Rule of thumb: an identifier that cannot outlive the question it answers is the only one you never have to defend.
Last updated on 9 October 2026.
