Tech 11 min read

The real core of a voting system is how you design the voting rights

IkesanContents

I have built character voting systems many times. The voting itself is simple: pick a candidate and save the vote to the database. It is not a particularly complicated system.

What really matters is the design of the voting rights: who can vote, and how many times.

If you say someone can vote once per day, how do you define that day? If voting uses a serial code bundled with a CD bonus item, how are those codes issued and verified? Can random attacks brute-force them?

This article summarizes the design and implementation points for each voting-right pattern.

The design of the voting target itself, such as one character versus multiple votes or a points-allocation model, is out of scope here.

Time-limited pattern

This is the pattern where voting rights reset by time, such as “once per day” or “every 12 hours”.

Identification methods and their weaknesses

MethodImplementationWeakness
CookieIssue a cookie when voting, check it next timeCan be bypassed by deleting it
LocalStorageRecord the vote in LocalStorageCan be deleted via dev tools, though it is a bit more annoying than cookies
IP addressRecord the IP on the server sideDynamic IPs, VPNs, and shared networks can affect other users
AccountRequire login and manage by user IDCan be bypassed with multiple accounts

None of them is perfect.

Cookies can be removed in the browser. IP addresses change because of dynamic allocation or VPNs. Even if you require an account, someone can just create more free email accounts.

LocalStorage is only slightly better than cookies. It requires opening dev tools to delete, so it acts as a mild deterrent for casual users.

A stronger version using LocalStorage + JWT

If you want LocalStorage to be a bit more robust, you can combine it with JWT.

Flow:

  1. When the site loads, issue a JWT from the server for the unvoted state, then store it in LocalStorage
  2. When the user votes, send the JWT -> verify the signature on the server -> process the vote -> issue a voted JWT
  3. On the next attempt, reject it because the token says “already voted”
// Example JWT payload
{
  "voted": false,           // becomes true after voting
  "lastVotedAt": null,      // filled after voting
  "exp": 1736661600         // expiration
}

The key point is that you need the JWT before voting.

Even if you clear LocalStorage, you still need two round trips - fetch a new JWT and then vote. Simply deleting LocalStorage is not enough.

Benefits:

  • Signature verification can detect tampering
  • The server can judge validity without hitting the database
  • Expiration can be embedded in the token itself
  • Voting history can be carried in the JWT for “you voted earlier” UX

SessionStorage can do the same thing, but LocalStorage has the advantage of keeping the history around, so you can show when the user last voted.

That said, a bot can still bypass this if someone writes automation. The goal is just to raise the friction of manual abuse.

Preventing consecutive votes with one-time tokens

The same JWT idea can be used with a very short lifetime, just a few seconds to a few tens of seconds.

Flow:

  1. Issue a one-time token when the voting page is shown
  2. Send the token on vote -> verify -> process the vote -> invalidate the token
  3. The next vote needs a new token

This is not for daily limits. It is for stopping rapid repeated bot voting.

Even if someone hits the voting API directly without a token, it gets rejected. Even if they fetch the token, it expires quickly. Manual voting is fine, but scripts need to do the fetch-and-vote round trip every time, and that pairs well with rate limiting.

Design decision

You cannot make fraud go to zero, so decide how much you are willing to tolerate.

  • Casual voting event -> cookies are enough; live with power users
  • Need some fairness -> account-based voting with limits on new accounts
  • Need stricter control -> add social login or a serial-code flow

Social-login pattern

This pattern verifies the person through login and grants voting rights.

Choosing a provider

Twitter, now X, used to be a common OAuth choice. But after API pricing changes in 2023, it became hard to justify for small projects.

A realistic option is Google login. It has a large free tier and is easy to implement. Firebase Authentication makes it even easier.

Authentication and voting rights are separate problems

The important thing to remember is that authentication does not prevent multiple accounts.

Google accounts are easy to create in bulk. X accounts are similar. “Identity verification” and “one person, one vote” are not the same problem.

Possible countermeasures:

  • Allow only accounts that require phone-number verification, such as X
  • Allow only accounts that have been in use for a certain amount of time
  • Detect suspicious voting patterns and review them manually

None of these is perfect, and they all cost engineering time.

Turn the vote into a social post

If you can use the X API, another option is to make voting post something to the user’s account.

If you auto-post via the API before saving the vote to the DB, repeated voting means repeated posts on the user’s timeline. That acts as a psychological deterrent, and if they do it anyway, well, it becomes promotion.

Serial-code pattern

This is the pattern where serial codes are distributed as a bonus item with a CD, DVD, or physical goods, and the user can vote by entering the code.

Issuer-side design

Random generation

Use cryptographically secure random numbers to generate the serial codes.

// C# example
var rng = new RNGCryptoServiceProvider();
byte[] bs = new byte[4];
rng.GetBytes(bs);
int seed = BitConverter.ToInt32(bs, 0);

System.Random alone can be predictable, so use RNGCryptoServiceProvider (RandomNumberGenerator in .NET 6+) to generate the seed.

Length and character set

Characters: a-z, A-Z, 0-9 (62 symbols)

Adding symbols increases the combinations, but it also makes typing harder on both keyboard and mobile. Do not overdo inconvenience in the name of security. Alphanumerics only is the better choice.

With 14 characters, the number of combinations is about 1.2×10^25.

In Japanese, that is about 12𥝱 combinations.

Even if someone can try one million codes per second, brute force would take about 380 billion years. Random attacks are not practical.

If you tell a client “there are 12𥝱 combinations, so it is fine”, it usually gets a laugh.

Realistically, human-readable strings top out around 7 characters, so UX would favor 6 to 7. But with a naive trust model, that would be too easy to brute force. Fourteen characters is much safer.

It is also useful to exclude confusing characters such as 0 and O, or 1 and l. That improves readability in print and reduces support requests caused by input mistakes.

If you add hyphens or spaces like XXXX-XXXX-XXXX for readability, the form-side handling is just an implementation detail, so I will skip it.

Patterns that are easy to guess

Reject these while generating:

  • Three identical characters in a row: aaa, 111, AAA, and so on
  • Three-step sequential strings: 123, abc, ABC, and the reverse forms

These are the kinds of patterns humans guess first, so do not include them.

It also helps to require three character classes: at least one digit, one lowercase letter, and one uppercase letter.

Duplicate checking

Issuing the same code to multiple people is fatal. Deduplicate with a HashSet during generation, and also keep a file or database of all previously issued codes to cross-check against.

' VB.NET example: deduplicate with HashSet
If hSet.Add(generatedCode) Then
    ' New code, accept it
Else
    ' Duplicate, regenerate
End If

Verification-side design

Register in the database

Pre-register all issued codes in the DB. The table design can look like this:

CREATE TABLE serial_codes (
    id INT AUTO_INCREMENT PRIMARY KEY,  -- easy to see how many were issued
    code VARCHAR(14) UNIQUE NOT NULL,   -- UNIQUE constraint as a second check
    used_at DATETIME NULL,
    used_by_ip VARCHAR(45) NULL,
    created_at DATETIME NOT NULL
);

Making id auto-increment helps you see how many were issued. If you need a second batch, you can separate them with ranges like “ID 10001 and up”.

Track used codes

Instead of physically deleting records, use logical deletion by writing the timestamp into used_at.

Reasons:

  • You keep a record of when the code was used
  • You can trace history during fraud investigations
  • You can return an accurate “already used” error
  • If you accidentally try to enter an old code during a later batch, the UNIQUE constraint will still reject it

Access logs

Recording IP addresses and timestamps when a code is entered helps with anomaly detection.

Many inputs from the same IP in a short period -> possible brute force attack

Once a threshold is exceeded, temporarily block that IP.

Should authentication be combined with the code?

Should users enter only the serial code, or should login be required too?

MethodProsCons
Code onlyLess friction for usersHarder to track who used it
Code + loginEasier to trace users and apply IP restrictionsMay cause drop-off

If anti-abuse matters more, combine it with login. But because bonus-item voting is often meant to be easy to join, decide based on the target audience.

Personally, I recommend authentication + serial code. It gives much better traceability and makes abuse detection significantly more accurate.

Shared security concerns

Form validation

This is the most important part. Validate on both frontend and backend.

  • Restrict character classes, for example alphanumerics only
  • Restrict length
  • Use placeholders to prevent SQL injection
  • Escape HTML

Frontend-only checks are easy to bypass with dev tools, so do not skip backend validation.

Rate limiting

Prevent too many requests in a short time.

  • At most N requests per minute from the same IP
  • At most N requests per minute from the same session
  • If the limit is exceeded, block temporarily or show CAPTCHA

reCAPTCHA

You can attach reCAPTCHA to the voting form, but surprisingly many sites do not.

The reason is simple: humans are sometimes treated like robots. Cloudflare Turnstile in particular has a fairly high false-positive rate, which is stressful for users. Google’s version is also annoying when it asks you to pick images.

If voting failure turns into a support issue, be careful about adopting it. That said, it is effective against bots, so it is a reasonable trade-off if you understand the cost.

CORS / XSS protection

  • Allow only the correct origins in CORS
  • Always escape user input before rendering
  • Set a Content Security Policy header

CORS alone does not stop someone from building a form on another site and posting to you directly. Countermeasures include:

  • Requiring a CSRF token
  • Combining it with the one-time token above
  • Restricting the request body to JSON only
  • Expiring page-embedded tokens so people cannot leave the page open forever

The last one is surprisingly effective. If the JS-based consecutive-vote protection can be bypassed, you can at least ask the user to refresh after a certain amount of time. It is redundant with CSRF and one-time tokens, but it works as layered defense.

Combining multiple measures works best.

UI tricks

For character voting, UI can improve both anti-abuse and UX.

Example: click the character directly + voice feedback

  1. After entering the serial code, click the character to vote
  2. The character’s voice asks, “Do you really want to vote?”
  3. After confirmation, play a “thank you” voice

That is more intuitive than a checkbox plus vote button, and fans will want to hear the voice, so the interval between votes naturally becomes longer. That also helps suppress rapid repeat voting.

Wrap-up

The core of a voting system is voting-right management.

  • Time-based limits are easy, but they have many loopholes
  • Social login does not fully prevent multiple accounts
  • Serial codes require thoughtful issuance, verification, and anomaly detection

Perfect abuse prevention is impossible. Decide how far to go based on the cost.

In the old days, you could identify a device with a device-specific value on mobile, but now iOS and Android will not let you obtain that without user permission. Even conversion tracking has gotten harder. Multiple devices, PCs, and network changes keep the cat-and-mouse game going.

The important thing is to be clear about what you are trying to protect. Do you care about fairness, or about maximizing participation? The best design changes depending on the goal.