Devlog 7: No Login Required - Device Fingerprinting for Anonymous Players

6 min read
authentication privacy ux

Devlog 7: No Login Required - Device Fingerprinting for Anonymous Players

One of my core principles for Tuggy: you should be able to land on a matchup and start voting within 2 seconds. No signup forms, no email confirmation, no friction.

But I still need to track who voted for what, both for leaderboards and to prevent simple vote manipulation. So I needed a way to identify anonymous users without requiring authentication.

Anonymous authentication system - device fingerprinting with progressive migration to authenticated accounts

Device Fingerprinting

The solution is generating a unique ID for each device/browser combo:

function getDeviceId(): string {
  let deviceId = localStorage.getItem('tuggy_device_id');

  if (!deviceId) {
    deviceId = generateFingerprint();
    localStorage.setItem('tuggy_device_id', deviceId);
  }

  return deviceId;
}

function generateFingerprint(): string {
  const components = [
    navigator.userAgent,
    navigator.language,
    screen.width,
    screen.height,
    new Date().getTimezoneOffset(),
  ];

  return hashComponents(components);
}

This creates a stable ID that persists across sessions. It's not perfect (clearing localStorage resets it), but it's good enough for casual use. And importantly, it's all client-side. No tracking cookies, no third-party services, just localStorage.

Dual Attribution System

In the database, votes can be attributed to either a user ID or a device ID:

interface Vote {
  matchup_id: string;
  user_id?: string;      // If logged in
  device_id?: string;    // If anonymous
  side: 'a' | 'b';
  amount: number;
}

The schema enforces that every vote has one or the other:

CREATE TABLE votes (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES auth.users,
  device_id TEXT,
  CHECK (user_id IS NOT NULL OR device_id IS NOT NULL)
);

So anonymous users and authenticated users work exactly the same way from the voting system's perspective.

Progressive Authentication

Here's the cool part: when an anonymous user eventually decides to sign up, I migrate all their device_id votes to their new user_id:

async function migrateAnonymousData(userId: string, deviceId: string) {
  await supabase
    .from('votes')
    .update({ user_id: userId, device_id: null })
    .eq('device_id', deviceId);

  await supabase
    .from('leaderboard')
    .update({ user_id: userId })
    .eq('device_id', deviceId);
}

So they don't lose any progress. Their votes, tugs, everything carries over seamlessly. This makes the signup decision feel safe because you're not starting over.

When To Prompt Signup

I don't shove signup in people's faces immediately. Instead, I prompt at strategic moments:

  • After earning an achievement
  • After reaching top 10 on a leaderboard
  • After voting on 3+ different matchups

Basically, once they're already engaged and seeing value, then I suggest they create an account to save progress across devices.

Privacy Considerations

I'm trying to be thoughtful about privacy here:

  • Device IDs are hashed and stored only in localStorage
  • No third-party tracking scripts
  • No personal data collected for anonymous users
  • Easy to "reset" by clearing localStorage
  • Full data export available on request

It's not as robust as real authentication, but it strikes a good balance between friction and functionality.

Results

Since launching with this system, about 85% of users vote anonymously and never sign up. That's fine! They still have a great experience. The 15% who do sign up tend to be power users who want leaderboards and cross-device sync.

Most importantly, nobody is bouncing because they hit a signup wall. The conversion funnel is: arrive → vote → maybe sign up later. Not: arrive → signup form → bounce.

Next week I'm adding analytics, which is going to be interesting to balance with the privacy-first approach I'm taking here.