If you have a personal blogging website, you might want to add a like button to your posts. This feature can help you understand which posts resonate with your audience and give you a sense of what content is most popular. However, would you expect your users to sign in to like a post? In my site, you might have seen a tree which grows when you click on it. This is a like button that doesn’t require any authentication. And you can like a post upto 5 times. Try opening it in incognito and you wills see that it still remembers the likes. This is your unique fingerprint. This is what I am going to show you how to do in this post. Open this page in incognito and see you will get the same fingerprint. Also try other browsers and you will get the same fingerprint if the browser is using the same engine.
Why would not you want to authenticate a like button?
There are plenty of personal websites that I visit and I don’t want to sign in to like a post. I just want to show my appreciation for the content. I don’t want to create an account or sign in with my social media account. I just want to click a button and move on. And its ok if its a little less secure. It doesn’t have to be perfect. It just has to work and avoid spam. This is the same experience that I want to provide to users visiting my site. I want them to like a post without any friction. And its ok if the same user who liked the post before came after few weeks and liked the post again. Personal websites are not big enough to worry about this.
What options do we have?
The first thought that comes to make this work is with cookie. But its extremely flawed. You can easily clear your cookies and like a post again. Not just that, I dont want to use cookies in the first place.
The next option could be validating the IP address. But this is also flawed. You can easily change your IP address and like a post again. Not just that if you are browsing a site from within a company’s network, all the users will have the same IP address and they can like a post only once. So it wont act as a unique identifier. Not just that, whatever internet I latch onto, I will have a different IP address. So I can like a post again which could be spam.
We can use the user agent string and the screen resolution to create a unique identifier. However, you can imagine you may get duplicates. For example, if you are using a public computer, you will have the same user agent string and screen resolution.
The next option could be trying to figure out a device id. But browsers wont make this possible as it would be a privacy concern and websites which are heavily into tracking will exploit this. However, we can take advantage of inconsistencies that are created by the browser during processing. One such inconsistency is the OfflineAudioContext
API. This API is used to create audio processing graphs. We can use this API to create a unique identifier for the user. This is what I have used in my site.
Audio Fingerprinting
One of the most interesting aspects of browser fingerprinting is audio fingerprinting. This method utilizes the Web Audio API to generate a unique audio signature based on mathematical waveforms. The beauty of audio fingerprinting lies in its uniqueness and stability. The audio signals generated through this technique can provide a reliable fingerprint that varies between browsers with different engine while remaining consistent across sessions.
The Web Audio API: Building Blocks
Before we dig deeper into audio fingerprinting, let’s briefly explore the Web Audio API—the backbone of this technique. This powerful system allows developers to manipulate audio through a series of interconnected nodes, creating complex audio graphs within an AudioContext.
-
AudioContext: This is the core of the audio system, managing audio node creation and processing execution.
-
AudioBuffer: This component stores audio snippets in memory, represented in LPCM (Linear Pulse Code Modulation) format.
-
Oscillator: An oscillator serves as a source of sound, generating periodic waveforms like sine, square, or triangular waves. The default frequency is 440 Hz, corresponding to the musical note A4.
-
DynamicsCompressorNode: This node reduces the volume of the loudest parts of the signal, preventing distortion. It has several properties that we can manipulate for variability across different browsers.
- Threshold: The volume level above which compression occurs.
- Knee: The smooth transition range above the threshold.
- Ratio: The input-output change ratio in decibels.
- Reduction: The current amount of gain reduction.
- Attack and Release: Time parameters for gain adjustment.
Each browser’s implementation of the Web Audio API introduces subtle differences in how audio is processed, influenced by factors like CPU architecture and operating system variations. Also, the dynamics compressor adds another layer of complexity by modifying audio signals based on adjustable parameters like threshold and ratio, which can vary between browsers.
This combination results in distinct audio waveforms for each user’s environment, making the generated audio fingerprints unique and stable identifiers that can persist across different sessions and even in incognito modes.
Creating an Audio Fingerprint
Now that we have the basic concepts, let’s dig into the audio fingerprinting process. We start by declaring some properties to store our audio context, oscillator, and compressor nodes, as well as the fingerprint itself. I wrote a class but you can choose to use a function as well.
class AudioFingerprint { #audioContext: OfflineAudioContext | null = null; #currentTime: number | null = null; #oscillatorNode: OscillatorNode | null = null; #compressorNode: DynamicsCompressorNode | null = null; #fingerprint: string | null = null; #onCompleteCallback: ((fingerprint: string) => void) | null = null; }
#
makes a property or a method private.
Next, we define our AudioContext. Safari supports a prefixed version, so we account for that.
#createAudioContext(): void { //@ts-ignore const OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; this.#audioContext = new OfflineContext(1, 5000, 44100); }
Next, we create an oscillator instance that generates a triangular waveform at 1,000 Hz:
#createOscillatorNode(): void { if (this.#audioContext) { this.#oscillatorNode = this.#audioContext.createOscillator(); this.#oscillatorNode.type = 'triangle'; this.#oscillatorNode.frequency.setValueAtTime(10000, this.#currentTime || 0); } }
We then introduce a compressor to add variability to our signal:
#createCompressorNode(): void { this.#compressorNode = this.#audioContext.createDynamicsCompressor(); this.#setCompressorValue(this.#compressorNode.threshold, -50); this.#setCompressorValue(this.#compressorNode.knee, 40); this.#setCompressorValue(this.#compressorNode.ratio, 12); this.#setCompressorValue(this.#compressorNode.attack, 0); this.#setCompressorValue(this.#compressorNode.release, 0.25); }
Next, we connect the nodes and generate the audio fingerprint:
createFingerPrint(callback: (fingerprint: string) => void, debug: boolean = false): void { this.#onCompleteCallback = callback; try { this.#initializeAudioContext(); if (this.#oscillatorNode && this.#compressorNode && this.#audioContext) { this.#oscillatorNode.connect(this.#compressorNode); this.#compressorNode.connect(this.#audioContext.destination); this.#oscillatorNode.start(0); this.#audioContext.startRendering(); this.#audioContext.oncomplete = this.#handleAudioComplete.bind(this); } } catch (error) { if (debug) { console.error('Audio Fingerprinting Error:', error); } } }
The samples array holds our audio data, and we derive a fingerprint from this by summing the absolute values of the sample array:
// reference - https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript#answer-52171480 export const calculateHash = function (str, seed = 0) { // `seed` is the only optional argument, so it can be the second parameter }; console.log(calculateHash(samples));
Where to Store the Hash?
So you still need a database. But this is easy. You can register in upstash
and create a redis database. You will be provided with a connection string which you can use to connect to the redis db. The data structure is simple where the key is the post slug and value can be a map of the hash and the count of likes. Something like this:
{ "post-xx-slug": { "userLikes": { "hash-1": 4, "hash-2": 2, "hash-3": 3 }, "count": 9 } }
Complete code
import { calculateHash } from './crypt'; class AudioFingerprint { // private #audioContext: OfflineAudioContext | null = null; #currentTime: number | null = null; #oscillatorNode: OscillatorNode | null = null; #compressorNode: DynamicsCompressorNode | null = null; #fingerprint: string | null = null; #onCompleteCallback: ((fingerprint: string) => void) | null = null; createFingerPrint( callback: (fingerprint: string) => void, debug: boolean = false ): void { this.#onCompleteCallback = callback; try { this.#initializeAudioContext(); if (this.#oscillatorNode && this.#compressorNode && this.#audioContext) { this.#oscillatorNode.connect(this.#compressorNode); this.#compressorNode.connect(this.#audioContext.destination); this.#oscillatorNode.start(0); this.#audioContext.startRendering(); this.#audioContext.oncomplete = this.#handleAudioComplete.bind(this); } } catch (error) { if (debug) { console.error('Audio Fingerprinting Error:', error); } } } #initializeAudioContext(): void { this.#createAudioContext(); if (this.#audioContext) { this.#currentTime = this.#audioContext.currentTime; this.#createOscillatorNode(); this.#createCompressorNode(); } } #createAudioContext(): void { //@ts-ignore const OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; this.#audioContext = new OfflineContext(1, 5000, 44100); } #createOscillatorNode(): void { if (this.#audioContext) { this.#oscillatorNode = this.#audioContext.createOscillator(); this.#oscillatorNode.type = 'triangle'; this.#oscillatorNode.frequency.setValueAtTime( 10000, this.#currentTime || 0 ); } } #createCompressorNode(): void { if (this.#audioContext) { this.#compressorNode = this.#audioContext.createDynamicsCompressor(); this.#setCompressorValue(this.#compressorNode.threshold, -50); this.#setCompressorValue(this.#compressorNode.knee, 40); this.#setCompressorValue(this.#compressorNode.ratio, 12); this.#setCompressorValue(this.#compressorNode.attack, 0); this.#setCompressorValue(this.#compressorNode.release, 0.25); } } #setCompressorValue(param: AudioParam, value: number): void { param.setValueAtTime(value, this.#audioContext!.currentTime); } #handleAudioComplete(event: OfflineAudioCompletionEvent): void { this.#generateFingerprint(event); if (this.#compressorNode) { this.#compressorNode.disconnect(); } } #generateFingerprint(event: OfflineAudioCompletionEvent): void { let output = ''; const channelData = event.renderedBuffer.getChannelData(0); for (let i = 4500; i < 5000; i++) { output += Math.abs(channelData[i]); } this.#fingerprint = output.toString(); if (typeof this.#onCompleteCallback === 'function' && this.#fingerprint) { this.#onCompleteCallback(this.#fingerprint); } } } // Expose this function export const getFingerPrint = async () => { return new Promise((resolve: (fingerprint: string) => void) => { const audioFingerprint = new AudioFingerprint(); audioFingerprint.createFingerPrint(async (fingerprint: string) => { fingerprint = window.btoa(fingerprint as string); resolve(calculateHash(fingerprint, 0) as unknown as string); }, true); }); };
Redis Query
You will need 2 functions for this.
One is to fetch all the likes of a particular post and the likes of the current user.
const data = await redis.get(slug); const userLikes = data.totalLikes[userId];
And the other one is to post a like. You should do some checks before saving. If you are allowing users to like more than once, then you should handle that carefully here.
await redis.set( slug, JSON.stringify({ count: likeData.count, totalLikes: { ...data?.totalLikes, [userId]: likeData.me }, }) );
For both the above functions, you need to send the fingerprint
from the client. Thats how you get the userId
.
Conclusion
Audio fingerprinting is a way to track users without all the annoying pop-ups and concents. For side projects and personal websites, having that overhead to authenticate users is not worth it. It does not work unless you are a well known author in the content you produce. I have tested this in Chrome, Safari, Arc and they produce the same fingerprint. And considering the market share of these browsers, I am happy with the results. What do you think?