If you've ever built a poll/survey, you've hit this wall: how do you stop one person from voting fifty times? It sounds simple but it isn't. Every approach trades off between friction and accuracy, and the "right" answer depends on what you're building.
Here's what I've learned.
Picking our poison: The trade-offs of tracking
IP-based fingerprinting
Hash the voter's IP to restrict votes to one per IP.
Except not really. A university campus shares one public IP. An entire office behind a corporate NAT looks like a single user. You just locked out hundreds of legitimate voters. On the flip side, anyone with a VPN rotates their IP in seconds and votes again.
It works well enough for casual polls where the stakes are low. It falls apart the moment someone actually wants to game it.
| Pros | Cons |
|---|---|
| Zero client-side code | Shared NAT blocks legit voters |
| No cookies to clear | VPN defeats it instantly |
| Works in any environment | Entire households = one vote |
Browser fingerprinting
Libraries like FingerprintJS build a hash from your screen resolution, installed fonts, GPU renderer, and timezone. Because this fingerprint relies entirely on your browser's hardware profile, it requires zero cookies and easily survives incognito mode.
Sounds clever, and it is. But two people on identical company laptops with the same OS image produce the same fingerprint. Browser updates change the hash. And privacy-focused browsers actively fight fingerprinting by randomising these signals. You're building on sand that shifts with every Chrome update.
| Pros | Cons |
|---|---|
| Survives incognito mode | Identical devices = same hash |
| No login required | Browser updates break it |
| Hard for casual users to dodge | Privacy browsers randomise signals |
| Adds a client-side dependency |
Cookie / localStorage tracking
The simplest approach is to drop a cookie or localStorage flag after voting. Check it before allowing another vote. But it is the easiest to defeat.
Clearing cookies or opening an incognito window bypasses it instantly. Even non-technical users know this trick.
That said: most people won't bother. If your poll is "Where should we eat this Friday," cookies are fine.
| Pros | Cons |
|---|---|
| Trivial to implement | Clear cookies = vote again |
| No server-side storage needed | Incognito bypasses it |
| Zero friction for voters | Provides no real security |
Account-based authentication
Mandatory login to tie one vote to one account. This is the gold standard for accuracy, if someone has to authenticate with Google or GitHub, creating duplicate votes means creating duplicate accounts across those providers. That's real friction for an attacker.
The cost is real friction for everyone else too. Requiring login for a casual poll kills participation. Half your audience bounces at the login screen. The ones who stay are a self-selected group, which skews your results in a different way.
| Pros | Cons |
|---|---|
| Strongest duplicate prevention | Kills participation on casual polls |
| Can't bypass from Postman | Self-selection bias in respondents |
| Audit trail for every vote | Requires OAuth setup / auth infra |
CAPTCHA (Cloudflare Turnstile, reCAPTCHA, hCaptcha)
CAPTCHAs don't prevent duplicate votes directly, rather they prevent bots. Cloudflare Turnstile is the nicest version of this, it proves a human is present, not that the human hasn't voted before.
You'd pair CAPTCHA with another method. CAPTCHA + IP hashing stops casual scripting via Postman or curl, the CAPTCHA token requires a browser environment, so raw API calls can't fake it. But a determined human can still solve the CAPTCHA repeatedly from different IPs.
Worth adding if bot spam is a realistic threat. Overkill for a team lunch poll.
| Pros | Cons |
|---|---|
| Blocks bots and scripts | Doesn't prevent human duplicates |
| Turnstile is invisible to most | Adds external dependency |
| Stops Postman/curl abuse | Must pair with another method |
Rate limiting
It helps to throttle submissions per IP, like allowing five votes per minute. This doesn't prevent duplicates, but it slows down brute-force attempts. It's a supporting measure, not a standalone solution.
The nice thing: it costs almost nothing to implement and catches the laziest attacks. The bad thing: it only catches the laziest attacks.
| Pros | Cons |
|---|---|
| Near-zero implementation cost | Only slows, doesn't prevent |
| Catches lazy scripted attacks | Useless against patient humans |
| Good as a supporting layer | Not a standalone solution |
Device attestation (Apple DeviceCheck, Android SafetyNet)
Mobile platforms offer APIs that let you mark a device as "already voted" at the hardware level. The user can't clear it. Factory reset doesn't help.
Powerful, but platform-locked, and web polls can't use it. And it ties your system to Apple's and Google's infrastructure, which is a dependency you might not want.
| Pros | Cons |
|---|---|
| Hardware-level, can't be faked | Mobile only, no web support |
| Survives factory reset | Platform-locked (Apple / Google) |
| Strongest anonymous prevention | Adds infra dependency |
How they compare
The Postman test
Here's the question nobody asks until it's too late: what happens when someone opens Postman, grabs your POST /api/poll/:id/respond endpoint, and starts firing requests?
Attacker with Postman / Curl / Python Script:
How each method holds up:
| Method | Postman spam? | Why |
|---|---|---|
| Cookies | Wide open | Postman doesn't send or store cookies by default. Every request looks like a fresh visitor. |
| IP hash | 1 vote goes through | Every request from the same machine has the same IP. First one lands, rest get rejected by the unique index. |
| IP + UA hash | 1 vote per UA string | Attacker can fake the User-Agent header. Each unique UA string = one vote. A loop with 100 random UAs = 100 votes. |
| Browser fingerprint | Wide open | Fingerprinting requires a real browser with a DOM. Postman sends raw HTTP, there's no canvas, no WebGL, no font list. The fingerprint never gets generated. No fingerprint = no dedup. |
| CAPTCHA | Blocked | Turnstile/reCAPTCHA tokens require a browser challenge, and Postman can't solve them. The request gets rejected before it even reaches your vote logic. |
| Auth (OAuth) | Blocked | Needs a valid session cookie from a real OAuth flow. Postman can't fake a Google login. No session = 401. |
| Rate limiting | Slowed | 5 requests get through per minute. The other 995 get 429'd. Doesn't prevent the 5 that land, just buys time. |
| Device attestation | Blocked | Requires a signed token from Apple/Google hardware APIs. Can't be generated from Postman. |
The uncomfortable truth about IP + UA hashing: it stops a lazy Postman test (same IP, same default UA = same fingerprint, duplicate rejected). But anyone who thinks to set a custom User-Agent header which is one line in Postman gets around it. The User-Agent is a client-supplied header. You're trusting the attacker to identify themselves honestly.
That's why CAPTCHA and auth are the only methods that truly block programmatic abuse. Everything else is a filter for laziness, not intent.
For Versus, I accepted this. The rate limiter catches the spray-and-pray scripts (5 req/min per IP), and the fingerprint index catches the ones that don't bother faking headers. A determined attacker with rotating IPs and random UAs could still stuff votes, but at that point, they're working harder than most people work at their actual jobs. For a poll about where to eat Friday, that's a risk I'll take.
What I actually used in Versus
I built a poll platform called Versus for a hackathon, and I needed something that worked for two very different modes: anonymous polls (anyone can vote) and authenticated polls (login required).
For authenticated polls, it's straightforward. Require OAuth login via Google or GitHub. Store respondent (user ID) on each response. A compound unique index on { poll, respondent } with a partialFilterExpression makes the database reject duplicates at the storage layer. One account with one vote is enforced by MongoDB. Can't be bypassed from Postman because you'd need a valid session cookie.
For anonymous polls, I went with IP + User-Agent hashing. When a vote comes in, I compute SHA-256(IP + User-Agent) and store it as a fingerprint on the response. Another compound unique index on { poll, fingerprint } rejects duplicates at the database level. The hash is one-way, and I never store raw IPs or user agents.
Why IP + UA instead of just IP? I had a realisation that my phone and laptop on the same WiFi share a public IP. Pure IP hashing meant my entire household got one vote per poll. Adding the User-Agent string means different devices (phone Safari vs. laptop Chrome) produce different hashes, even on the same network. The same browser on the same device still can't double-vote, but at least my family can each have an opinion.
I chose not to add CAPTCHA or browser fingerprinting just to keep it simple and flexible. The main threat is someone idly hitting refresh, not a coordinated bot attack. IP+UA hashing catches that. And the cost of false negatives (one person switching browsers to vote twice) is low.
If I needed higher assurance, I'd add Cloudflare Turnstile as a bot gate and push users toward authenticated mode.
How the rest of the industry does it
While building Versus, I studied what existing platforms actually do. The interesting thing: their anti-duplicate strategy almost always mirrors their business model.
Casual poll platforms optimise for participation. Survey tools optimise for response quality. Social platforms lean on account reputation. Enterprise tools optimise for auditability. And actual election systems treat accuracy as non-negotiable, friction be damned.
Casual polls (StrawPoll, Mentimeter, Poll Everywhere)
StrawPoll implements Cookies, IP tracking, optional CAPTCHA, rate limiting. They openly acknowledge that VPNs bypass their limits, and duplicate prevention is "best effort." Their goal is stopping casual spam.
Mentimeter and Poll Everywhere optimise for the "join instantly with a code" experience. Session limits, per-device restrictions, throttling, abuse detection. But they accept that a determined user can vote multiple times, because their use case is audience interaction during a live presentation. Locking down the poll harder than the room it's running in doesn't make sense.
Survey platforms (SurveyMonkey, Google Forms, Typeform)
SurveyMonkey's strongest mechanism is single-use survey links. Each respondent gets a unique URL (survey.com/r/xyz123token), and the token is invalidated after submission. For anonymous surveys, they fall back to cookies, IP restrictions, and session tracking. For controlled surveys, they have email invitations, unique links, authenticated respondents. The unique-link approach is extremely common in enterprise surveys.
Google Forms gives creators a toggle: "Limit to 1 response," which requires a Google account. Without it, there's essentially no strong duplicate prevention, they rely on cookies and Google's internal abuse detection. The hidden advantage Google has is that they already know enormous amounts about device reputation and traffic patterns. Small apps can't replicate that.
Typeform is interesting because they care more about response quality than strict uniqueness. They'll detect suspicious patterns and flag low-quality responses rather than hard-blocking. Cookies, hidden fields, optional auth, reCAPTCHA, and behavioural fraud systems for enterprise tiers.
Enterprise (Qualtrics)
This is where things get serious. Qualtrics supports SSO, signed links, panel management, fraud scoring, behavioural analytics, bot detection, geo analysis, and digital fingerprinting. They also do things like "speeding" detection (completed too fast to be human) and "straight-lining" detection (selecting the same option repeatedly). At this level, fraud analysis matters more than prevention, because sophisticated survey fraud is impossible to fully prevent.
Social media (X, Reddit)
X Polls has one vote per authenticated account. Internally, they likely layer account trust, age, phone verification, and spam reputation. But the visible enforcement is just "log in."
Reddit Polls: also account-based, but Reddit has an enormous hidden advantage, which is account reputation history. A 7-year-old active account is far more trustworthy than u/free_crypto_airdrop_19382. They can weight trust internally in ways that new platforms can't.
Actual voting systems (ElectionBuddy, Simply Voting)
Student government, union elections, shareholder voting use unique voter rolls, single-use cryptographic tokens, email verification, identity verification, audit logs, and cryptographic receipts. Some support anonymous-but-verifiable voting. The philosophy flips completely: accuracy over participation friction.
What the industry is converging toward
Pure IP blocking is considered weak now. CGNAT, mobile carriers, VPNs, IPv6 privacy rotation, an IP address is a weak signal, not an identity. Browser fingerprinting is weakening too. Safari ITP, Firefox anti-fingerprinting, Brave randomisation, Chrome's privacy sandbox direction. fingerprinting is now one signal among many, not the core system.
Most modern platforms are converging toward a layered model:
Versus sits squarely in the casual-poll tier and that's intentional. Low-friction anonymous mode, stronger authenticated mode, database-level uniqueness, rate limiting, and a pragmatic threat model. The biggest industry-standard addition I'm currently missing is probably a lightweight bot/risk layer like Cloudflare Turnstile, plus optional trust scoring over time.
But that's a problem I am looking forward to solve.
Versus is live here
This was a small project for hackathon, but I would be making some updates in near future. I welcome all the ideas, features and bug reports on GitHub.




Top comments (0)