Blog · November 10, 2026 · 9 min read

Stopping duplicate votes without making anyone log in

The feedback board I build lets anyone vote on an idea in one click, with no account and no email address. The first question people ask about that is reasonable: what stops someone voting a hundred times?

The honest answer

The honest answer is “several small checks, none of them perfect”. This post walks through each one with the real code, what it costs, and what it still can’t stop. If you’re building anything with anonymous votes, likes or ratings, the same pieces apply.

Why skip logins at all is a separate argument, covered in its own post. The short version: every step before a vote loses people, and for product feedback you want the quiet majority, not just the motivated few.

What we’re actually defending against

It helps to be specific, because “stop duplicate votes” can mean very different things:

  • Someone clicking twice by accident, or double-clicking. Must never count twice.
  • Someone voting again on purpose after a reload, or in a second tab. Should be stopped.
  • Someone trying a bit harder: private window, another browser, clearing storage. Should be limited.
  • A script sending votes straight to the API. Should be capped hard.
  • A determined person with many devices and networks. Can’t be stopped without identity, and for a feedback board that’s fine.

An election needs to handle all five. A feedback board needs to make the first four not worth the effort, and to make sure the numbers stay roughly right. You’re looking for patterns across many people, not a precise count.

Check 1: a random token per board, kept in the browser

The first time someone votes or suggests an idea, the widget makes a random token and keeps it in the browser’s local storage:

const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const token = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
store.set(`usernote_token_${boardId}`, JSON.stringify({ token, created: Date.now() }));

Every vote sends that token, and the server records one vote per token per idea. A reload, a second tab, or clicking again tomorrow all send the same token, so they’re all the same voter. That covers the first two cases.

A few decisions around it:

  • It’s random, not a fingerprint. It isn’t built from the screen size, fonts or anything else about the device. A fingerprint would survive clearing storage, which is exactly why I didn’t want one: it’s tracking, and visitors can’t see it or reset it.
  • One token per board. Two boards on the same website get different tokens, so nobody can link what one visitor did on both.
  • It’s replaced after 12 months, and a “Forget my votes on this device” link at the bottom of the board deletes it. The votes already cast still count.
  • Local storage can throw (some privacy modes, blocked site data), so the widget falls back to keeping the token in memory for the page’s lifetime. Voting still works; it just won’t be remembered next visit.

The weakness is obvious: the token belongs to the browser, so a private window or a different browser gets a new one. And the server can’t tell a real token from a made-up one. It only checks the format, so a script can send a fresh random token with every request. The token alone stops honest repeats; the next checks are for everything else.

Check 2: hash it on the server

The server never stores the token as sent. It stores a salted hash, combined with the board ID:

export function hashClient(...parts) {
  return createHash('sha256')
    .update([process.env.HASH_SALT ?? '', ...parts].join('\n'))
    .digest('base64url')
    .slice(0, 32);
}

const voteKey = { PK: `NOTE#${ideaId}`, SK: `VOTE#${hashClient('token', boardId, token)}` };

If the database ever leaked, the vote records wouldn’t contain anything a browser could present again. The same function hashes IP addresses (next section), and there the salt really matters: there are only about 4.3 billion IPv4 addresses, so an unsalted hash of one can be reversed by trying them all. With a secret salt, the stored value is useless without it. The database only ever holds the hashes.

Check 3: a cap per network

Here’s what handles the next two cases. Each idea keeps a small counter per hashed IP address, and one connection can only contribute a few votes to any one idea, however many tokens it invents.

Why a few and not one? Because one IP address is often many people: an office, a school, a household, a phone network sharing addresses between thousands of customers. A limit of one would mean the second person in an office could never vote. A small number is a guess at the balance: it lets a small team vote for the same thing, while one person with several browsers, or a script on one connection, only gets a handful of extra votes per idea.

There are also hourly limits per connection, on votes across the board and on new suggestions. Those are simple counters in the same DynamoDB table, one item per IP per hour, deleted automatically by a TTL:

export async function rateLimit(key, limit, windowSeconds) {
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - (now % windowSeconds);
  const result = await ddb.send(new UpdateCommand({
    TableName: table(),
    Key: { PK: `RATE#${key}`, SK: String(windowStart) },
    UpdateExpression: 'ADD hits :one SET #ttl = :ttl',
    ExpressionAttributeNames: { '#ttl': 'ttl' },
    ExpressionAttributeValues: { ':one': 1, ':ttl': windowStart + windowSeconds + 60 },
    ReturnValues: 'UPDATED_NEW',
  }));
  if (result.Attributes.hits > limit) throw new HttpError(429, 'Too many requests');
}

(The key is the hashed address, never the address itself.) A fixed window is the simplest kind of rate limit, and it allows a burst at the edge of two windows (up to double the limit in a few seconds). For this, that doesn’t matter.

Getting the real IP address

An IP cap is only as good as the IP address you read, and this is the step that’s easy to get wrong. The API sits behind CloudFront, so the address the Lambda sees by default is CloudFront’s, not the visitor’s. The usual fix is to read a forwarded header, but a client can set any header it likes.

So a tiny CloudFront function overwrites that header on every API request with the address CloudFront actually saw (event.viewer.ip), whatever the client sent.

That’s only trustworthy if every request goes through CloudFront. Someone calling API Gateway’s own URL directly could set the header to anything. CloudFront therefore adds a long secret header to each request it forwards, and the API rejects any request without it. Without that second step, the whole per-IP cap could be sidestepped by changing one header.

Check 4: one transaction, so the count can’t drift

A vote touches three things: the record that this token voted, the per-IP counter for this idea, and the idea’s vote count. If they’re written separately, a failure or a race between them leaves the count wrong: two fast clicks could both pass a “has this token voted?” read before either writes.

So it’s one DynamoDB transaction with a condition on each part, and any failed condition cancels all three (table names and expression values trimmed for space):

TransactItems: [
  { Put: { Item: { ...voteKey, timestamp: now },
           ConditionExpression: 'attribute_not_exists(PK)' } },          // this token hasn't voted
  { Update: { Key: ipKey,
              UpdateExpression: 'SET votes = if_not_exists(votes, :zero) + :one',
              ConditionExpression: 'attribute_not_exists(votes) OR votes < :max' } }, // IP under its cap
  { Update: { Key: ideaKey,
              UpdateExpression: 'ADD voteCount :one',
              ConditionExpression: 'attribute_exists(PK) AND #status <> :pending' } }, // idea exists and is public
]

There’s no read-then-write gap: the conditions are checked as part of the write. A double-click sends two requests; one succeeds and the other fails its first condition. Transactions cost twice as much as plain writes in DynamoDB, which on a small board is a fraction of a cent.

Check 5: say yes to everything

When a vote is a duplicate, or over the IP cap, or over the hourly limit, the API still answers 200 with { voted: true }. It just doesn’t count it.

This is deliberate. A clear “you’ve already voted” or “limit reached” tells someone scripting votes exactly which check stopped them and when to rotate to something else. Silent success gives them nothing to tune against. Honest visitors almost never hit these limits, and when they do, the widget already showed their vote, so nothing changes for them.

Check 6: a honeypot for suggestions

New ideas get one more check. The suggestion form has an extra field with an ordinary-looking name, positioned off-screen, skipped by the keyboard (tabindex="-1"), and hidden from screen readers (aria-hidden="true") and from autofill (autocomplete="off").

People never see it, so they never fill it in. Simple form-filling bots fill every field they find. If it arrives with a value, the API answers as if the idea was accepted and throws it away. Like the vote checks, it fails silently.

It’s not on votes, because a vote isn’t a form: there’s nothing for a bot to fill in. It’s also only good against lazy bots, which is most of them. Anything that runs the page properly won’t fall for it, and that’s what the IP limits and owner approval (new ideas wait for the board owner by default) are for.

What this can’t stop

Being clear about the limits matters more than the checks themselves:

  • Several networks. Someone with a laptop on home broadband and a phone on mobile data has two IP addresses, so two separate caps. Add a VPN and it’s more.
  • Several IPv6 networks. One I got wrong first time: the cap used the full IP address. That’s fine for IPv4, but an IPv6 device normally picks new addresses within its network every so often (privacy extensions), and each new address got a fresh allowance. The cap now counts IPv6 by its /64 network, the block a home or a phone is normally given, and IPv4 by the address as before. It still isn’t watertight: some providers give a home many /64 networks, and someone who can switch between them gets an allowance on each.
  • A real group voting together. If a company tells 40 employees to upvote its request from their own phones, those are 40 real votes from 40 people. No check can or should stop that; the board owner will usually notice the pattern.
  • The next person in a busy office. The flip side of the per-IP cap: once a few colleagues have voted for an idea from the same office connection, the next colleague’s vote is dropped silently. Their browser shows it as voted; the count doesn’t include it. That’s the trade I made for the caps to mean anything.

None of this is identity. If you need to know exactly who voted, or votes decide money or rankings that people have a reason to cheat, require a login. For “which of these ideas do people want most”, these checks keep the numbers honest enough to act on, without asking anyone to sign up.

The short version

  • A random token per board, in the browser, stops honest repeat votes.
  • The server stores only salted hashes of tokens and IP addresses.
  • A small cap per IP per idea (per /64 for IPv6), plus hourly limits, bounds what one connection can do, provided you read the IP from a header your own CDN set and block requests that skip the CDN.
  • One conditional transaction keeps the count right under double-clicks and races.
  • Duplicates and limits fail silently, so there’s nothing to tune against.
  • A honeypot catches lazy bots on the suggestion form.

What have you used for anonymous voting, and what got through it?

Disclosure: this is how UserNote, the feedback board and public roadmap I build and run on my own, handles votes.