Cookieless analytics on any platform - stop showing annoying consent banners
- Authors
- https://jaslong.com
- Last updated
Recently, we found ourselves staring at the same problem over and over: we needed solid usage data, but didn’t want to mess with cookies -— not just because consent banners are a pain, but also due to other GDPR concerns, users blocking trackers, and overall to make the internet a better place to be in.
After a ton of debates the main question on the table was still the following: how to track user identity across sessions without cookies? And the answer is: yes, we can!
If you’re in a simillar situation, ask yourself the following questions:
Need to implement a cookie consent banner?
Stop! There are so many reasons that consent banners are sub-optimal:
- They are really annoying
- Most users opt-out or ignore
- Analytics data is inconsitent due to opt-outs
- Costly to build or buy a consent banner
- They impact the page speed score, lowering the SEO
But there’s a better way to track users — without cookies.
Why are consent banners required for traditional analytics tracking?
Traditional analytics platforms work by assigning each visitor a unique, persistent identifier - let’s call it a user ID. This ID allows the analytics system to track individual user journeys by connecting related events like page views, clicks, and conversions across multiple sessions.

For sites without user accounts (like marketing sites), these user IDs are typically randomly generated strings that get stored in the visitor’s browser as a cookie or in local storage. Every time the user interacts with the site, their stored user ID is sent along with the event data to the analytics system.
However, with modern data privacy regulations like the EU’s GDPR and ePrivacy Directive, sites are legally required to obtain explicit user consent before storing any non-essential data such as user IDs in the browser. This is why you see those intrusive cookie consent banners everywhere - they’re a legal requirement for traditional analytics tracking.
So how can we track users without storing data in their browsers? The solution lies in leveraging data that’s already available in every HTTP request.
Cookieless solution: server-generated user ID
By analyzing the HTTP request headers that browsers automatically send to web servers, we can generate a stable user ID without relying on browser storage. Specifically, we can use the visitor’s IP address, which is included in every request. While storing IP addresses is considered personally identifiable information (PII), we can hash it with a daily-rotating salt to anonymize it. Other signals such as the user agent can also be included in the hash to increase entropy.
Here is the computation for our new user ID:
user_id = hash(daily_salt + ip_address + user_agent)
Why do we need a salt?
Without a salt, an attacker can precompute a rainbow table using all IP addresses and known user agents. Then they can easily reverse all user IDs to their IP addresses.

Related to this, the salt must be rotated regularly and never persisted permanently (e.g. do not write it to logs). Otherwise, an attacker can precomute rainbow tables for each salt they get their hands on.
Trade-offs
Cookieless analytics offers significant advantages: it eliminates intrusive consent banners, simplifies privacy compliance, and maintains consistent data quality across all users. The technical implementation is straightforward, requiring no cookie management or banner interfaces, while better protecting user privacy through data anonymization. However, the main limitation is the restricted tracking window.

With daily salt rotation, we can only track users within 24-hour windows. A visitor who makes three visits in one day counts as one unique user, but the same visitor appearing on three different days counts as three distinct users. This means we can measure daily unique visitors but cannot track monthly uniques or analyze long-term user journeys.
Overall, cookieless analytics are ideal for sites prioritizing user experience and data privacy over extended behavior tracking.
Implementation
The simplest way to go cookieless is to use an analytics system that already supports server-generated user IDs. One example is Plausible. Kudos to them for not using any browser storage on their marketing site, despite my IP address from the US!
But if you already use another analytics system, you’ll need a reverse proxy. In the end the data flow should look something like this:

Reverse proxy
At Plasmic, we use PostHog. Since PostHog don’t currently support server-generated user IDs (see tracking issue), we opted to build a reverse proxy that injects user IDs before sending to PostHog.
A reverse proxy is a server that forwards requests to other servers. In our case, before forwarding a request, we will generate the user ID and inject it into the request. Reverse proxies also have the added benefit of avoiding most ad-blockers.

Here’s an example repo for a PostHog reverse proxy, which you can easily deploy to Cloudflare: https://github.com/plasmicapp/analytics-rproxy. The app is based on the Hono framework which makes it a breeze to deploy to Cloudflare or other edge runtimes.
Let’s go over the critical code path. Here’s processIdentifiedEvents', which loops over the events in the request, looks for unidentified events (i.e. events with randomly generated user ID) and replaces the
distinct_idand
$device_id` properties with the server-generated user ID.
// posthog.ts
/**
* Processes unidentified events, injecting stable-ish user IDs to the request.
* Overwrites distinct_id, $device_id, and $session_id fields.
*/
export async function processUnidentifiedEvents(
events: PostHogEvent[],
ipAddress: string,
saltManager: SaltManager
) {
for (const event of events) {
if (event.properties?.$is_identified === false) {
const timestampMs = (event.properties.$time ?? Date.now() / 1000) * 1000
const timezone = event.properties.$timezone || 'Etc/UTC'
const userAgent = event.properties.$raw_user_agent || 'unknown-user-agent'
const sessionTimeoutMs = event.properties.$configured_session_timeout_ms || 1800000
const salt = await saltManager.getSalt(timestampMs, timezone)
const userId = await generateUserId(salt, ipAddress, userAgent)
const sessionId = await generateSessionId(userId, timestampMs, sessionTimeoutMs)
event.properties.distinct_id = userId
event.properties.$device_id = userId
event.properties.$session_id = sessionId
}
}
return events
}
The first thing we do is get the salt for the day, based on the event timestamp and timezone. The timezone is important so that we can set a reasonable boundary between days, which in this case is midnight. The same visitor would have a different user ID at 23:59 and 00:00 of the next day based on their local timezone. If we just used UTC and didn’t consider the timezone, then the boundary might be at critical visitor times like 12:00 noon, inflating your daily unique visitors metric.
The salt should be generated a few minutes before the start of the day for residents of Kiribati, the earliest timezone at UTC+14. See the cron trigger in wrangler.jsonc
. If the salt is missing, the code will generate and store a new one, just in case.
// salt.ts
export class SaltManager {
async getSalt(timestampMs: number, timezone: string): Promise<string> {
const date = timestampToPlainDate(timestampMs, timezone)
const key = plainDateToString(date)
const salt = await this.tryGetSalt(key)
if (salt) {
return salt
} else {
console.warn('missing salt for ' + key)
return this.setSalt(key)
}
}
}
Now that we have the salt, we can hash it along with the IP address and user agent. We also attached a “pcookieless” prefix to indicate that the ID came from this cookieless solution.
// posthog.ts
/** Generates a user ID based on salt, IP address, and user agent. */
export async function generateUserId(
salt: string,
ipAddress: string,
userAgent: string
): Promise<string> {
const input = `${salt}:${ipAddress}:${userAgent}`
const digest = await crypto.subtle.digest('SHA-256', strToU8(input))
return `pcookieless_${btoa(String.fromCharCode(...new Uint8Array(digest)))}`
}
The app also has some other interesting code bits for you to explore on your own:
- session IDs
- UUID v7 generation
- plain date and timezone functions
- gzip encryption/decryption
And that’s it! If you use PostHog, this code should just work for you. If not, you’ll need to make some adjustments to replace PostHog with your own analytics system.
Follow @plasmicapp on Twitter for the latest updates.