Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
Count Unique Visitors Without Cookies Using Daily Salted Hashes in Express
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Count Unique Visitors Without Cookies

  1. Install Express, the pg PostgreSQL client, and dotenv in a new Node.js project.
  2. Create a PostgreSQL schema with a daily_salts table and a visits table that enforces uniqueness on site, hash, and date.
  3. Generate a 256-bit cryptographic salt each UTC day and cache it in-memory to avoid repeated database lookups.
  4. Compute sha256(daily_salt | site_id | ip | user_agent) in Express middleware, truncating to 16 hex characters.
  5. Insert each hash with ON CONFLICT DO NOTHING so PostgreSQL silently deduplicates repeat visits.
  6. Query the visits table with a simple COUNT(*) to retrieve daily unique visitor totals.
  7. Evaluate failure modes—CGNAT undercounting, midnight-UTC splits, and 64-bit collision risk—and apply mitigations as needed.

The infrastructure underpinning traditional web analytics is crumbling. This tutorial builds a privacy-first, server-side unique visitor counter using Express, PostgreSQL, and Node.js crypto—along with an honest accounting of where it breaks down.

Table of Contents

Why Cookieless Visitor Counting Matters

The infrastructure underpinning traditional web analytics is crumbling. GDPR and the ePrivacy Directive impose strict consent requirements on cookie-based tracking across the EU. Safari's Intelligent Tracking Prevention (ITP) and Firefox's Enhanced Tracking Protection (ETP) aggressively limit cookie lifetimes and block cross-site trackers. Chrome has kept third-party cookies (Google dropped its plan to remove them and said in April 2025 it would not add a standalone prompt), but the broader shift continues to reshape how developers think about cookieless analytics and unique visitor counting.

The fallout is real. Consent banners create friction, cookie-based analytics suffer from incomplete data as users decline tracking, and tools like Google Analytics without cookies lose the ability to count unique visitors or track events while still routing data through third-party infrastructure. Matomo's cookieless mode removes cookie dependency but introduces a full-stack analytics platform that many smaller projects don't need.

Compute sha256(daily_salt | site_id | ip | user_agent), truncate to 16 hex characters, and deduplicate against that hash in the database. No cookies, no localStorage, no client-side fingerprinting scripts.

This tutorial builds a privacy-first, server-side unique visitor counter using Express, PostgreSQL, and Node.js crypto. Compute sha256(daily_salt | site_id | ip | user_agent), truncate to 16 hex characters, and deduplicate against that hash in the database. No cookies, no localStorage, no client-side fingerprinting scripts. What follows is the full implementation, along with an honest accounting of where it breaks down.

How the Daily Salted Hash Technique Works

The Core Algorithm

The approach concatenates four values into a single string, delimited by null bytes to prevent field-boundary ambiguity: a cryptographically random salt that rotates daily, a site identifier, the visitor's IP address, and their User-Agent header. SHA-256 hashes that string, producing a 64-character hex digest. The digest is then truncated to 16 hex characters (64 bits) to produce a compact daily visitor ID. 16 hex characters (64 bits) balances storage efficiency with acceptable collision probability for sites under roughly 100,000 daily uniques; increase to 32 for higher-traffic sites.

Truncation to 64 bits keeps storage lean and index performance high. Whether that truncation introduces meaningful collision risk depends on traffic volume, a question addressed in detail in the failure-mode analysis below.

How This Preserves Privacy

SHA-256 is a one-way function. No one can reverse the stored hash to recover the IP address or User-Agent string. Because the salt rotates every day, no one can correlate Monday's hash with Tuesday's hash for the same visitor. Cross-day tracking is structurally impossible, provided old salts are deleted or kept confidential; an attacker with access to historical salts could re-derive hashes and correlate visits across days. The server writes no state to the client: no cookies, no localStorage entries, no fingerprinting JavaScript. It processes the request, computes the hash, and stores only the truncated, non-reversible result.

Setting Up the Project

Prerequisites

This tutorial assumes Node.js 18 or later, PostgreSQL 14 or later, and either npm or yarn. Readers should be comfortable with Express middleware patterns and basic SQL. The crypto module is built into Node.js and requires no installation.

Project Structure

Create the following files in your project directory:

cookieless-analytics/
├── app.js
├── salt.js
├── middleware.js
├── .env
└── package.json

Initialize the Project and Install Dependencies

mkdir cookieless-analytics && cd cookieless-analytics
npm init -y
npm install express@4 pg@8 dotenv@16

Pin these versions in package.json or run npm install --save-exact for fully reproducible installs.

The crypto module ships with Node.js and does not need to be installed separately. The dotenv package loads database credentials and configuration from a .env file, keeping secrets out of source control.

Create a .env file with your database connection string and site identifier:

# .env — replace credentials before use; never commit this file to version control
DATABASE_URL=postgresql://user:CHANGE_ME@localhost:5432/analytics
SITE_ID=my-site
STATS_API_KEY=replace-with-a-strong-secret

Add .env to your .gitignore:

echo ".env" >> .gitignore

PostgreSQL connection string format: postgresql://[user]:[password]@[host]:[port]/[dbname]. The database must already exist; create it with createdb analytics (or your preferred name) if needed.

PostgreSQL Schema for Deduplication

Designing the Tables

The schema uses two tables. The daily_salts table stores one cryptographic salt per UTC date. The visits table stores each deduplicated visitor hash along with the site identifier and date. A unique constraint on the combination of site_id, visitor_hash, and visited_on enables deduplication at the database level using ON CONFLICT DO NOTHING, which means the application code can blindly insert every request and let PostgreSQL silently discard duplicates.

Run the following SQL against your analytics database using psql $DATABASE_URL or your preferred PostgreSQL client:

CREATE TABLE IF NOT EXISTS daily_salts (
    date DATE PRIMARY KEY,
    salt TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS visits (
    id SERIAL PRIMARY KEY,
    site_id TEXT NOT NULL,
    visitor_hash VARCHAR(16) NOT NULL CHECK (char_length(visitor_hash) = 16),
    visited_on DATE NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_visit UNIQUE (site_id, visitor_hash, visited_on)
);

CREATE INDEX IF NOT EXISTS idx_visits_site_date ON visits (site_id, visited_on);

The visitor_hash column uses VARCHAR(16) with a CHECK constraint rather than CHAR(16) to avoid PostgreSQL's space-padding behavior with fixed-width character types, which can cause subtle comparison bugs. The IF NOT EXISTS guards allow re-running the schema file safely against an existing database.

The index on (site_id, visited_on) ensures that daily unique count queries avoid sequential scans. The unique constraint uq_visit simultaneously serves as the deduplication mechanism and as an index, so no additional index on those three columns is necessary.

Querying Unique Visitor Counts

Because every row in the visits table already represents a unique visitor for a given site and day, counting requires no DISTINCT clause:

SELECT COUNT(*) AS unique_visitors
FROM visits
WHERE site_id = $1
  AND visited_on = $2;

This query uses the idx_visits_site_date index to avoid a full table scan; query time grows linearly with the number of matching rows for the given site and date.

Generating and Rotating the Daily Salt

Salt Generation Logic

Each day's salt is a 256-bit value generated by crypto.randomBytes(32) and stored as a 64-character hex string. Storing it in the daily_salts table keyed by UTC date ensures deterministic retrieval throughout the day and reproducibility for any request processed on that date.

UTC Salt Rotation

Save the following as salt.js:

const crypto = require('crypto');

let cachedSalt = { date: null, salt: null };
let inflightRequest = null;

async function getDailySalt(pool) {
    const todayUTC = new Date().toISOString().slice(0, 10);

    if (cachedSalt.date === todayUTC) {
        return cachedSalt.salt;
    }

    if (inflightRequest) {
        return inflightRequest;
    }

    inflightRequest = _fetchOrCreateSalt(pool, todayUTC).finally(() => {
        inflightRequest = null;
    });
    return inflightRequest;
}

async function _fetchOrCreateSalt(pool, todayUTC) {
    const result = await pool.query(
        'SELECT salt FROM daily_salts WHERE date = $1',
        [todayUTC]
    );

    if (result.rows.length > 0) {
        cachedSalt = { date: todayUTC, salt: result.rows[0].salt };
        return cachedSalt.salt;
    }

    const newSalt = crypto.randomBytes(32).toString('hex');
    await pool.query(
        'INSERT INTO daily_salts (date, salt) VALUES ($1, $2) ON CONFLICT (date) DO NOTHING',
        [todayUTC, newSalt]
    );

    // Re-fetch in case another process inserted first
    const confirmResult = await pool.query(
        'SELECT salt FROM daily_salts WHERE date = $1',
        [todayUTC]
    );
    if (!confirmResult.rows.length) {
        throw new Error(`Failed to retrieve or insert salt for date ${todayUTC}`);
    }
    cachedSalt = { date: todayUTC, salt: confirmResult.rows[0].salt };
    return cachedSalt.salt;
}

module.exports = { getDailySalt };

UTC is mandatory here. If the server derives the date from a local timezone, multi-region deployments will generate different salts for the same calendar moment. The in-memory cache avoids a database round-trip on every request within a single process. The inflightRequest variable deduplicates concurrent calls at UTC midnight rollover: when multiple requests miss the cache simultaneously, only one database round-trip is issued and all waiters share the result. In multi-worker deployments, each worker maintains its own cache and will independently query the database once per UTC day on its first request; correctness across workers is ensured by ON CONFLICT DO NOTHING and the re-fetch, not by the cache. The cache resets naturally when the UTC date rolls over.

Building the Express Hash Middleware

Extracting Visitor Signals

Three signals feed the hash: the visitor's IP, their User-Agent header, and a site identifier. The middleware reads req.ip, but you must configure Express with app.set('trust proxy', 1) (or a more specific value matching your infrastructure) when the application sits behind a reverse proxy, load balancer, or CDN. Without this setting, req.ip returns the proxy's address rather than the client's, collapsing all visitors into a single hash. The User-Agent comes from req.headers['user-agent']. Load the site identifier from an environment variable or pass it as a route parameter.

Computing the Visitor Hash

Save the following as middleware.js:

const crypto = require('crypto');
const { getDailySalt } = require('./salt');

function createVisitorCounter(pool, siteId) {
    return async function visitorCounter(req, res, next) {
        try {
            const salt = await getDailySalt(pool);
            const ip = req.ip || '0.0.0.0';
            const userAgent = req.headers['user-agent'] || '';
            const todayUTC = new Date().toISOString().slice(0, 10);

            // null-byte delimiter prevents field-boundary ambiguity
            const raw = [salt, siteId, ip, userAgent].join('\x00');
            const hash = crypto
                .createHash('sha256')
                .update(raw)
                .digest('hex')
                .substring(0, 16);

            await pool.query(
                `INSERT INTO visits (site_id, visitor_hash, visited_on)
                 VALUES ($1, $2, $3)
                 ON CONFLICT ON CONSTRAINT uq_visit DO NOTHING`,
                [siteId, hash, todayUTC]
            );

            req.visitorHash = hash;
            next();
        } catch (err) {
            console.error('Visitor counter error:', err);
            req.visitorHash = null;
            next();
        }
    };
}

module.exports = { createVisitorCounter };

The middleware fails open: if the database is unreachable or the query errors, the request proceeds without blocking the user. The ON CONFLICT ... DO NOTHING clause means repeated visits from the same browser on the same day produce inserts that are silently discarded, and the unique count in the visits table remains accurate.

Wiring the Middleware into the App

Save the following as app.js:

require('dotenv').config();
const assert = require('assert');
const express = require('express');
const { Pool } = require('pg');
const { createVisitorCounter } = require('./middleware');

if (!process.env.DATABASE_URL) {
    throw new Error('DATABASE_URL environment variable is required');
}

const app = express();
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    connectionTimeoutMillis: 3000,
    statement_timeout: 5000,
    idle_in_transaction_session_timeout: 10000
});
const siteId = process.env.SITE_ID || 'default-site';
assert(siteId.length <= 64, 'SITE_ID too long');

// trust one proxy hop by default; set TRUST_PROXY to match your infrastructure.
// Using `true` accepts any X-Forwarded-For value and enables IP spoofing.
const trustProxy = process.env.TRUST_PROXY ?? '1';
app.set('trust proxy', /^\d+$/.test(trustProxy) ? Number(trustProxy) : trustProxy);
const visitorCounter = createVisitorCounter(pool, siteId);

app.get('/', visitorCounter, (req, res) => {
    res.send(`Hello! Your visitor hash: ${req.visitorHash}`);
});

app.get('/stats', (req, res, next) => {
    const apiKey = process.env.STATS_API_KEY;
    if (!apiKey || req.headers['x-api-key'] !== apiKey) {
        return res.status(401).json({ error: 'Unauthorized' });
    }
    next();
}, async (req, res, next) => {
    try {
        const todayUTC = new Date().toISOString().slice(0, 10);
        const result = await pool.query(
            'SELECT COUNT(*) AS unique_visitors FROM visits WHERE site_id = $1 AND visited_on = $2',
            [siteId, todayUTC]
        );
        res.json({
            date: todayUTC,
            uniqueVisitors: Number(result.rows[0].unique_visitors)
        });
    } catch (err) {
        next(err);
    }
});

process.on('SIGTERM', async () => {
    await pool.end();
    process.exit(0);
});
process.on('SIGINT', async () => {
    await pool.end();
    process.exit(0);
});

app.listen(3000, () => console.log('Listening on port 3000'));

The middleware is attached to the / route only, so every page view is hashed and deduplicated while requests to /stats are not counted as visits. To count every route, mount it with app.use(visitorCounter) before the routes and exclude /stats yourself. The /stats endpoint is protected by an API key check: set the STATS_API_KEY environment variable and pass the key in the x-api-key request header. It returns a JSON object with the day's unique visitor count. The Pool is configured with connection and statement timeouts to prevent hung queries from stalling the application, and SIGTERM/SIGINT handlers ensure database connections are released cleanly on shutdown.

Testing the Implementation

Manual Verification

These tests assume all curl requests originate from the same IP as seen by the server (e.g., 127.0.0.1 when running locally without a proxy). If counts are unexpected, add console.log(req.ip) to the middleware to verify the IP being used.

# First request with a specific User-Agent
curl -s -H "User-Agent: Mozilla/5.0 TestBrowserA" http://localhost:3000/

# Same request again — should produce the same hash, no new row
curl -s -H "User-Agent: Mozilla/5.0 TestBrowserA" http://localhost:3000/

# Different User-Agent — different hash, new unique visitor
curl -s -H "User-Agent: Mozilla/5.0 TestBrowserB" http://localhost:3000/

# Check the count — should be 2 (requires STATS_API_KEY to be set)
curl -s -H "x-api-key: $STATS_API_KEY" http://localhost:3000/stats

# Verify /stats rejects unauthenticated requests
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/stats
# Expected: 401

The first two requests should return the same visitorHash value, confirming deduplication. The third request, with a different User-Agent, should produce a distinct hash and increment the count to 2.

Confirming Salt Rotation

To verify that salt rotation produces different hashes for the same visitor on different days, insert a row into daily_salts with yesterday's date and a known salt value. Then temporarily mock the date derivation in getDailySalt to return yesterday's date. Replaying the same cURL request will produce a different hash, confirming that cross-day correlation is impossible without the original salt.

Failure Modes and Mitigations

CGNAT and Shared-IP Collisions

Carrier-grade NAT (CGNAT) places thousands of mobile users behind a single public IP address. If two users on the same carrier share both an IP and the same browser User-Agent string, their hashes will be identical, and they will be counted as one visitor. Corporate networks and university campuses produce the same effect. In mobile-heavy audiences, expect single-digit to low-teens percent undercounting as an engineering estimate; no authoritative cross-industry measurement exists, and the actual figure depends on your audience composition. Adding Accept-Language or Accept-Encoding headers to the hash input provides limited additional entropy but moves the technique closer to browser fingerprinting, which may undermine the privacy rationale.

Midnight-UTC Boundary Splits

A visitor who loads a page at 23:59 UTC and navigates to another page at 00:01 UTC will be hashed with two different daily salts, producing two distinct visitor hashes. This overcounts by one for each visitor who spans the boundary. The mitigation is dual-window hashing: on each request, compute the hash with both today's and yesterday's salt, and check the visits table for either. If either hash already exists, skip the insert. This adds a second database lookup and more code complexity but eliminates midnight-split overcounting. This tutorial does not implement dual-window hashing; the description above provides the logic, but production code should be tested thoroughly against edge cases at the UTC boundary.

64-Bit Truncated Hash Collision Risk

The birthday problem governs collision probability for truncated hashes. With a 64-bit hash space, there is a 50% probability of at least one collision at approximately 5 billion hashes (derived from the birthday bound: √(2 × 2⁶⁴ × ln 2) ≈ 5.06 × 10⁹). For a site with 1 million daily unique visitors, the collision probability is approximately 0.0000027% (p ≈ n²/(2 × 2⁶⁴) ≈ 2.7 × 10⁻⁸), which is negligible. For sites exceeding 100,000 daily uniques that require high precision, increasing the truncation from 16 hex characters (64 bits) to 32 hex characters (128 bits) pushes the 50% collision threshold to approximately 22 quintillion hashes, effectively eliminating the concern.

Other Considerations

IPv6 cuts both ways. It reduces shared-address collisions, but many devices rotate temporary addresses under privacy extensions, which can count one person more than once in a day. Bot traffic inflates counts; filter it by checking User-Agent strings against known crawler signatures before hashing. On the legal front, some EU data protection authorities may classify IP address hashing as personal data processing even when the original IP is not stored, because the hash is derived from personal data. Organizations subject to GDPR should consult legal counsel before deploying this technique without a privacy impact assessment.

Approximation with known error bounds beats cookie-based counts that half the audience has opted out of.

Failure ModeDirection of ErrorTrigger ConditionMitigationComplexity
CGNAT / shared IPUndercountSame IP + same UAAdd more headers to hash inputLow
Midnight-UTC splitOvercountVisitor spans UTC midnightDual-window salt checkMedium
64-bit hash collisionUndercount>100K daily uniquesIncrease to 128-bit truncationLow
Bot trafficOvercountUnfiltered crawlersUA-based bot filterLow
Proxy/CDN IP maskingUndercountMisconfigured trust proxySet trust proxy correctlyLow

How This Compares to Existing Cookieless Analytics

Matomo's cookieless tracking mode uses a conceptually similar hashing approach but ships as a full analytics platform with reporting, segmentation, and its own database layer. Plausible Analytics employs a daily-salted hash technique (see Plausible's published privacy policy and source code for exact implementation details) and operates as a hosted SaaS, abstracting away all infrastructure concerns. Google Analytics without cookies remains limited in capability and still routes data through Google's infrastructure, implicating data-sharing considerations under GDPR. The DIY approach presented here gives you ownership of the database, the hashing logic, and the query layer. The only runtime dependencies are Express and pg. The trade-off: you build and maintain everything yourself, including the failure modes documented above.

The DIY approach presented here gives you ownership of the database, the hashing logic, and the query layer. The only runtime dependencies are Express and pg.

Next Steps

This tutorial produced a working, cookieless unique visitor counter that stores no raw IP addresses or User-Agent strings; whether derived hashes constitute personal data under GDPR is jurisdiction-dependent (see the legal note in Failure Modes). The technique resists cross-day correlation and deduplicates at the database level with zero client-side state. The trade-offs are real: CGNAT causes undercounting, midnight boundaries cause overcounting, and legal classification of IP-derived hashes remains jurisdiction-dependent. Approximation with known error bounds beats cookie-based counts that half the audience has opted out of.

Suggested enhancements:

  • Add page-path tracking to the visits table for per-page analytics.
  • Build a dashboard with Chart.js to visualize trends.
  • Schedule cleanup of old salts via pg_cron to limit table growth.
  • Incorporate Accept-Language into the hash input for environments where you have measured the accuracy delta on your own traffic and decided the additional fingerprint surface is acceptable.
SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.