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)}`;
};
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;
};
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;
};
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());
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);
});
And a test pins it down:
expect(deriveIpRoomId('2001:db8:abcd:0012::1', secret))
.toBe(deriveIpRoomId('2001:db8:abcd:0012::abcd', secret));
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);
};
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);
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)