Every indie app gets hammered by bots eventually.
Sond — the trip planning app — hit this around month 2. Suddenly 40% of new accounts were fake. They'd load the app, click around for 3 seconds, and vanish. The conversion metrics were garbage.
I couldn't add login requirements. That would kill onboarding. So I built device fingerprinting instead.
The idea: create a unique identifier for each device based on hardware and behavioral signals. Bots usually appear from the same fingerprint repeatedly. Real users don't.
What Makes a Device Unique?
A "fingerprint" combines signals that are hard to spoof:
Hardware signals:
- Device model (iPhone 14 vs iPhone 15)
- Screen resolution and density
- CPU architecture
- Available memory
Browser/OS signals:
- OS version
- User-agent
- Timezone
- System language
- Font list — this one's weird but effective
- Canvas rendering output
Network signals:
- IP address (VPN users mess this up)
- ASN (internet provider)
- Connection type (WiFi vs cellular)
Behavioral signals:
- How fast they interact (bots are instant, humans have latency)
- Touch pressure (bots can't replicate this)
- Scroll velocity and acceleration
- Time between taps
The key: never rely on a single signal. Bots are good at spoofing one or two. Combine 10+, and they fail.
Canvas Fingerprinting (The Clever Trick)
Here's a subtle one: canvas fingerprinting.
You draw text and shapes to an HTML5 canvas, then read the pixel data. The exact pixels differ based on:
- Font rendering engine
- GPU implementation
- Anti-aliasing algorithm
It's a fingerprint of the device's graphics stack. Hard to fake without actually rendering on the target hardware.
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 280;
canvas.height = 60;
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.globalCompositeOperation = 'multiply';
ctx.fillRect(15, 15, 112, 20);
ctx.fillStyle = '#FFFFFF';
ctx.font = '17px "Arial"';
ctx.textBaseline = 'alphabetic';
ctx.fillText("Canvas fingerprint", 2, 15);
return canvas.toDataURL();
}
Bots fail here because they run headless (no GPU), so the rendering is different.
WebGL Fingerprinting (More Powerful)
WebGL exposes the GPU vendor and model:
function getWebGLFingerprint() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
return { vendor, renderer };
}
You get things like:
- "Apple" / "Apple M1"
- "Qualcomm" / "Adreno 642L"
Bots running on cloud servers report generic GPUs or headless renderers. Exposed immediately.
Building the Fingerprint
Combine all signals into a single hash:
async function generateDeviceFingerprint() {
const signals = {
model: navigator.userAgent,
screen: `${window.innerWidth}x${window.innerHeight}x${window.devicePixelRatio}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
canvas: getCanvasFingerprint(),
webgl: getWebGLFingerprint(),
maxTouchPoints: navigator.maxTouchPoints,
connection: navigator.connection?.effectiveType,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: navigator.deviceMemory,
};
const fingerprint = await hashObject(signals);
return fingerprint;
}
Store this in local storage and send it with every request. On the backend, track the fingerprint and count requests.
The Results
With fingerprinting in place, patterns became clear:
- Real users: 1 fingerprint, maybe 5-20 requests over a week
- Bots: 1-2 fingerprints, 100+ requests per hour
- Distributed bots: Different fingerprints from same IP, same request pattern
I flagged accounts with:
-
50 requests in <1 hour from a new fingerprint
- Same request pattern from >5 different fingerprints in <1 minute
- Canvas/WebGL fingerprint from a cloud provider (AWS, GCP, Azure)
Result: 95% bot filtering, zero false positives on real users.
The Ethical Side
Device fingerprinting can feel creepy to privacy-conscious users. I don't hide it — it's in the Terms of Service:
"To prevent abuse, we use device fingerprinting to detect and block automated access."
I don't use it to track users across sites. Fingerprints are device-local and app-specific. If someone clears app data, their fingerprint resets. Users can request deletion.
Alternatives I Considered
- Captcha — Works, but kills UX. 15% of users abandon on captcha.
- Email verification — Bots farm emails. Not sufficient alone.
- Phone verification — Kills international UX. SMS rates are expensive.
- IP geolocation — Easy to spoof with VPNs. Blocks real users in some regions.
- Behavioral analysis — Requires 1-2 weeks of data. Too slow.
Fingerprinting + rate limiting is the fastest, least invasive option.
The Bigger Picture
Device fingerprinting is a tool, not a silver bullet. Combined with IP rate limiting, email validation, and behavioral analysis, it becomes powerful.
But the real lesson: don't build authentication into your onboarding if you don't need to. Build detection instead. Identify bots after the fact, block them, keep the user experience frictionless.
Your conversion rate will thank you.