ka
Khairul Azharsenior software engineer
All writing
TechnicalAug 16, 2026 · 5 min

I Built Browser Fingerprinting Into My Portfolio. Here’s How Far It Gets You

My analytics thought my laptop and phone were strangers. sha256(ip + UA) breaks in both directions, so I rebuilt visitor identity with three layers: a first-party UUID, a FingerprintJS v5 fingerprint with fuzzy drift-matching, and the old IP hash demoted to last resort.

KA
Khai
Senior Software Engineer
I Built Browser Fingerprinting Into My Portfolio. Here’s How Far It Gets You

For two years, my portfolio’s analytics thought my laptop and my phone were strangers. Same person, same couch, same website. Two visitors. And when a friend joined my wifi with the same browser, the analytics quietly merged us into one.

The culprit was the visitor identity itself: sha256(ip + userAgent). Half the indie web runs on this scheme, and it breaks in both directions. A network change forks one person into many. A shared network merges many people into one. I even had a cleanup script whose whole job was apologising for it after the fact.

So I rebuilt visitor identity properly, with browser fingerprinting as one layer. Not the foundation. That distinction turned out to be the whole lesson.


Fingerprinting can’t be your primary key

Before writing any code I looked at what browsers actually do to fingerprinting in 2026:

  • Safari ships Advanced Fingerprinting Protection by default. Canvas noise, “Apple GPU” instead of your real GPU, fixed values everywhere.

  • Firefox standardises audio and canvas output in strict mode and buckets hardware into groups like “Apple M1, or similar”.

  • Brave randomises canvas and WebGL per session, per site. They call it farbling.

  • Chrome, which is most of the traffic, still produces stable device-specific output.

So a fingerprint is stable for most visitors and churns every session for the privacy-conscious ones. Build on it alone and your “unique visitors” number is fiction for exactly the users who care most.


Three layers, one resolver

Every tracking ping now carries up to three identifiers, and the server resolves them into one canonical visitor:

Priority 1  vid      : first-party UUID (localStorage + cookie mirror, 400 days)
Priority 2  fp       : FingerprintJS v5 hash + raw components
Priority 3  ip_hash  : the old sha256(ip + UA), demoted to last resort

The resolution ladder walks down until something matches:

exact_vid    : the stored UUID matched. Strongest signal.
exact_fp     : storage was cleared, but the fingerprint hash matched.
               The server re-issues the old vid and the client re-adopts it.
fuzzy_fp     : the hash drifted (browser update), but enough components
               still match. More on this below.
ip_fallback  : nothing else matched. The old world.
new          : genuinely never seen before.

Every visitor gets tagged with the layer that resolved them, and the admin dashboard shows that identity_confidence honestly. I would rather see “this visitor is an ip-fallback guess” than pretend everything is exact.

FingerprintJS, by the way, went back to MIT licensing in v5 (v4 was BSL), which is what made it viable here. It loads as a lazy chunk with a 1.5 second budget, and if it times out or throws, the ping just goes out with the other two layers. Identity must degrade, never veto.


Fuzzy matching absorbs the drift

A fingerprint hash is all-or-nothing: one browser update changes one component and the whole hash flips. But the raw components barely moved. So I store the components too, and when the hash misses, a fuzzy pass scores the incoming set against recent stored ones:

  • Weighted equality over shared components. Canvas, audio, fonts, WebGL and friends count double; volatile ones like screen frame and timezone offsets are excluded entirely.

  • A match requires a score of at least 0.85 and at least 8 shared stable components, because a threshold alone means nothing when only three components overlap.

  • Every fuzzy match is logged to an audit table with its score, so a bad merge is traceable instead of silent.

In verification, a simulated browser update (same components, one changed, different hash) resolved back to the same visitor at a score of 0.947. That is the case the old system lost every single time.


Merges leave a paper trail

Sometimes layer 1 and layer 2 disagree: the cookie says visitor A, the fingerprint says visitor B. That means two identities were created for one human at some point. The resolver prefers the first-party id, repoints B’s fingerprint to A, and writes a merge event into an identity_events audit log. Who absorbed whom, which fingerprint, when.

My old setup did this with an offline script that grouped rows by IP and hoped. Now it happens at resolution time, reversibly, with receipts.


If you track, disclose

Fingerprinting for analytics sits under GDPR and ePrivacy rules in the EU, and Malaysia’s PDPA is trending the same direction. Even for a personal portfolio, the honest move costs almost nothing:

  • Raw IP addresses are never stored. Identity uses hashes, geo stays at city level.

  • A plain-language privacy page names every identifier and what it is for. No legalese.

  • Every visitor can be erased with one action: a cascade across all eight tables that reference them, verified down to zero orphan rows.


What actually changed

Cross-network returns, the exact case sha256(ip + UA) structurally cannot see, now show up in the dashboard with their own metric. My Telegram ping says “returning visitor, 3rd visit, last seen 2d ago, resolved via exact_vid” instead of greeting me as a stranger every time I leave the house.

And the fingerprint? It earns its keep in exactly one scenario: storage got cleared, the fingerprint bridged the gap, and the visitor got their old identity back. That is it. That is the job.

Fingerprinting is a fallback, not a foundation. A first-party UUID in localStorage beats every canvas trick ever written. It is deterministic, it is honest, and no browser is at war with it. Build the boring layer first, and let the clever layer catch what falls through.

Next essay

I Don’t Let My AI Review Its Own Code

An AI agent built me a page where the save buttons did not exist, and every automated check said ship it. The rule that caught it: the agent that builds is never the agent that verifies. Execution contracts, proving commands, isolated test state, and a month of receipts.

ka

I design and ship resilient mobile platforms and the backends that keep them honest.

© 2026 · Privacy · v4.2.0 · commit 8a3f12c