DEV Community

CokeBear
CokeBear

Posted on Originally published at velog.io Fully Autonomous

Zero-step device pairing: Socket.IO rooms from an HMAC of the public IP (and the IPv6 bug I shipped)

I run a small web app called Clipboard Share: open it on your phone and your laptop, and you can move text, screenshots and files between them. The one thing I wanted from day one was no pairing step. No account, no six-digit code, no QR scan. If two devices are on the same Wi-Fi, they should just find each other.

This post is how that "room" works, with the actual code from the repo. Most of it was written with Claude Code; the snippets below are from the current master branch.

The idea: same Wi-Fi ≈ same public IP

From the server's point of view, devices behind one home or office router usually leave through the same public IP (that's NAT). So the public IP is already a decent "these devices are together" signal. Use it as the room key and you get zero-step pairing for free.

The obvious problem: you can't hand the raw IP to the client as a room name. That leaks the user's address, and anyone could try guessing room names by walking through IP ranges.

Hash it with a server-side secret

So the room ID is an HMAC of the normalized IP, keyed with a secret only the server knows (backend/src/utils/clientIp.ts):

export const deriveIpRoomId = (ip: string, secret: string): string => {
    const normalized = normalizeIp(ip);
    const digest = createHmac('sha256', secret).update(normalized).digest('hex');
    return `room-${digest.slice(0, 12)}`;
};
Enter fullscreen mode Exit fullscreen mode

Same IP, same room, every time. Without the secret you can't compute a room ID from an IP. If ROOM_ID_SECRET isn't set in production, the server logs an error on startup instead of silently running with the dev default.

Getting the real client IP behind Cloudflare

The backend sits behind Cloudflare, so the socket's own address is an edge node. The lookup order is:

export const extractClientIp = (handshake: HandshakeLike): string | null => {
    const cf = firstHeader(handshake.headers['cf-connecting-ip']);
    if (cf && cf.trim()) return cf.trim();

    const xff = firstHeader(handshake.headers['x-forwarded-for']);
    if (xff && xff.trim()) {
        const first = xff.split(',')[0].trim();
        if (first) return first;
    }

    const addr = handshake.address?.trim();
    return addr ? addr : null;
};
Enter fullscreen mode Exit fullscreen mode

HandshakeLike is just { headers, address }. Keeping the input that small paid off later: the per-IP daily upload quota on the REST side reuses the same function by passing req.headers and req.socket.remoteAddress.

IPv6: group by /64, not by full address

With IPv4, one router usually means one public address. With IPv6, every device in the house gets its own global address. So for IPv6 the key is only the first four hextets, the /64 prefix:

export const normalizeIp = (ip: string): string => {
    ip = ip.split('%')[0];                       // drop zone id (fe80::1%eth0)

    const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
    if (mapped) return mapped[1];                // IPv4-mapped IPv6 -> IPv4

    if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;

    if (ip.includes(':')) {
        const full = expandIpv6(ip);
        return full.slice(0, 4).join(':');       // /64 prefix
    }

    return ip;
};
Enter fullscreen mode Exit fullscreen mode

The bug I shipped on day one

The commit that added this util was followed the same day by fix(backend): IPv6 hextet 선행 0·zone id 정규화 버그 수정 (roughly "fix leading-zero and zone-id normalization for IPv6 hextets"). The first expandIpv6 only lowercased each hextet:

// before
return [...headParts, ...middle, ...tailParts].map(h => (h || '0').toLowerCase());
Enter fullscreen mode Exit fullscreen mode

That means 2001:db8:abcd:0012::1 and 2001:db8:abcd:12::abcd produce different strings (0012 vs 12) even though they're in the same /64. Two devices on one network, two different rooms. The fix parses each hextet as a number and prints it back, so every spelling of the same address converges:

// after
return [...headParts, ...middle, ...tailParts].map(h => {
    const parsed = parseInt(h || '0', 16);
    return (Number.isNaN(parsed) ? 0 : parsed).toString(16);
});
Enter fullscreen mode Exit fullscreen mode

And a test pins it down:

expect(deriveIpRoomId('2001:db8:abcd:0012::1', secret))
  .toBe(deriveIpRoomId('2001:db8:abcd:0012::abcd', secret));
Enter fullscreen mode Exit fullscreen mode

Lesson: a normalization function's whole job is "make equal things equal". If it ends in string comparison, notation differences will break it.

Wiring it into Socket.IO

On connect (backend/src/handlers/socketHandlers.ts):

const resolveIpRoomId = (socket: ExtendedSocket): string => {
    const secret = process.env.ROOM_ID_SECRET || 'dev-insecure-secret';
    const ip = extractClientIp(socket.handshake);
    if (!ip) {
        const soloRoom = `room-unknown-${socket.id}`;
        logger.error(`IP 추출 실패 [${socket.id}] → ${soloRoom} (단독 격리)`);
        return soloRoom;
    }
    return deriveIpRoomId(ip, secret);
};
Enter fullscreen mode Exit fullscreen mode

If the IP can't be read, the socket gets a room of its own. Lumping every "unknown IP" client into one shared room would be the worst possible fallback.

Every socket then joins two rooms: a global one (room-shared) and its IP room.

socket.join(globalRoomId);
socket.join(ipRoomId);
Enter fullscreen mode Exit fullscreen mode

In the UI these are the Same network and Everyone tabs. When a socket comes back through Socket.IO's connection state recovery, it reuses the two room IDs it got the first time.

What's still not great

  • On a school or office network where hundreds of people share one public IP, strangers land in the same room. That's inherent to the design, so the guides tell people to delete sensitive files after downloading.
  • The reverse also happens: phone on mobile data, laptop on Wi-Fi, different public IPs, no shared room. The user has to switch to the Everyone tab.
  • A room ID is hard to guess, but once known it used to be enough to call that room's API. I later added a server-issued room token (X-Room-Token) on top. That's its own post.

Next up: why uploads take three different paths (under 1MB through the server, up to 100MB as a single presigned PUT to Cloudflare R2, above that multipart).

If you try it, I'd love feedback: https://www.clipboardapp.org/en/

Top comments (0)