技術 約9分で読めます

投票システムの本質は「投票権」の設計にある

キャラ投票システムを何度も作ってきた。投票自体は「誰に票を入れるか選んでDBに保存」という単純な仕組みで、大したシステムではない。

本当に考えるべきは 「誰に、何回投票させるか」 という投票権の設計だ。

1日1回投票可能と言われたら、何でその1日を判定するのか。CD特典のシリアルコードで投票と言われたら、そのコードはどうやって発行・検証するのか。ランダムアタックで突破されないのか。

この記事では、投票権のパターンごとに設計と実装のポイントをまとめる。

なお、投票対象の設計(1キャラ投票か複数投票か、ポイント分配式か)は今回スコープ外とする。

時間制限パターン

「1日1回」「12時間ごと」など、時間で投票権をリセットするパターン。

識別方法とその穴

方式実装
Cookie投票時にCookie発行、次回チェック削除で回避可能
LocalStorage投票時にLocalStorageに記録開発者ツールで削除可能(Cookieより少し面倒)
IPアドレスサーバー側でIP記録変動IP、VPN、同一ネットワーク内の別人を巻き込む
アカウントログイン必須、ユーザーIDで管理複垢で回避可能

どれも完璧ではない。

Cookieはブラウザ操作で消せるし、IPは動的割り当てやVPNで変わる。アカウント制にしても、フリーメールで複垢を量産されたら同じことだ。

LocalStorageはCookieよりマシという程度。開発者ツールを開かないと消せないので、カジュアルユーザーには一定の抑止力になる。

LocalStorage + JWTの発展形

LocalStorageをもう少し堅牢にするなら、JWTを組み合わせる手がある。

フロー:

  1. サイトアクセス時にサーバーからJWT発行(未投票状態)→ LocalStorageに保存
  2. 投票時にJWTを送信 → サーバーで署名検証 → 投票処理 → 投票済みJWTを再発行
  3. 次回投票時は「投票済み」JWTなので弾く
// JWTのペイロード例
{
  "voted": false,           // 投票後はtrue
  "lastVotedAt": null,      // 投票後に日時が入る
  "exp": 1736661600         // 有効期限
}

ポイントは「投票前にJWTが必要」という点。

LocalStorageを消しても、新しいJWT取得→投票という2往復の通信が必要になる。単にLocalStorageを消すだけでは投票できない。

メリット:

  • 署名検証で改ざん検知できる
  • サーバー側でDB参照せずに有効性を判定可能
  • 有効期限をトークン自体に埋め込める
  • 投票履歴もJWTに含められる(「さっき投票したでしょ?」の表示に使える)

SessionStorageでも同じことはできるが、LocalStorageなら投票履歴が残るので、UX的に「前回いつ投票したか」を表示できる利点がある。

ただし、これも自動化スクリプトを書かれたら突破される。「手動で消すのが面倒」という心理的障壁を上げる程度と割り切る。

ワンタイムトークンで連続投票防止

同じJWTの仕組みで、有効期限を超短く(数秒〜数十秒)設定するパターンもある。

フロー:

  1. 投票ページ表示時にワンタイムトークン発行
  2. 投票時にトークン送信 → 検証 → 投票処理 → トークン無効化
  3. 次の投票には新しいトークンが必要

これは「1日1回」の制限とは別の目的で、botによる連続投票(秒間何十回とか)を防ぐためのもの。

トークンなしで投票APIを直接叩いても弾かれるし、トークンを取得しても数秒で期限切れになる。人間が手動で投票する分には問題ないが、スクリプトで大量投票しようとすると「トークン取得→投票」を毎回やる必要があり、レートリミットと組み合わせると効果的。

設計判断

「不正を0にする」のは無理なので、 何をどこまで許容するか を決める。

  • カジュアルな投票イベント → Cookieで十分、ガチ勢は諦める
  • ある程度の公平性が必要 → アカウント制 + 新規アカウントに制限
  • 厳密にやりたい → SNS認証や後述のシリアルコード併用

SNS認証パターン

ログインで本人確認し、投票権を付与するパターン。

プロバイダの選択

以前はTwitter(現X)のOAuth認証がよく使われていた。しかし2023年以降のAPI有料化で、個人〜中小規模のプロジェクトには厳しくなった。

現実的な選択肢としてはGoogle認証が挙がる。無料枠が大きく、実装も容易。Firebase Authenticationを使えばさらに楽になる。

認証と投票権は別問題

ここで注意が必要なのは、 認証できても複垢は防げない ということ。

Googleアカウントは簡単に複数作れる。Xも同様。「本人確認」と「1人1票の担保」は別の問題だ。

対策として考えられるのは:

  • 電話番号認証必須のアカウントのみ許可(Xなど)
  • 一定期間以上利用しているアカウントのみ許可
  • 不審な投票パターンを検知して手動確認

ただし、どれも完璧ではないし、実装コストもかかる。

投票=SNS投稿にする

X APIが使える環境限定だが、「投票したらあなたのアカウントに投票内容が投稿されます」という仕組みにする手もある。

API経由で自動投稿した後にDBへ投票を記録する流れにすれば、連続投票すると本人のタイムラインに連投される。心理的な抑止力になるし、仮に連投されても宣伝になるのでまあいいか、という割り切りもできる。

シリアルコードパターン

CD・DVD・グッズなどの封入特典としてシリアルコードを配布し、それを入力すると投票できるパターン。

発行側の設計

乱数生成

シリアルコードの生成には暗号学的に安全な乱数を使う。

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

System.Randomだけだとシード値が推測される可能性があるため、RNGCryptoServiceProvider(.NET 6以降はRandomNumberGenerator)でシードを生成する。

桁数と文字種

使用文字: a-zA-Z0-9(62種)

記号を入れると組み合わせは増えるが、キーボードでもスマホでも入力が面倒になる。セキュリティのために面倒さを上げすぎるのは本末転倒なので、英数字のみがおすすめ。

14桁の場合、組み合わせは約 1.2×10^25通り になる。

日本語で書くと 約12𥝱(じょ)通り

仮に1秒間に100万回試行できても、全探索には約3800億年かかる計算。現実的にランダムアタックは不可能。

クライアントへの説明で「12𥝱通りあるので大丈夫です」と言うと、だいたいウケる。

本当は、人間がパッと見で視認できる文字数は7文字程度が限界という話があり、UXを考えれば6〜7文字にしたい。ただ、そんな性善説では簡単に突破されてしまう。14桁あればまず安心。

紛らわしい文字(0O1l)を除外するオプションも有効。印刷物での視認性が上がり、入力ミスによる問い合わせが減る。

なお、印刷時に XXXX-XXXX-XXXX のようにハイフンやスペースを入れて視認性を上げる場合、入力フォーム側でどう扱うか(区切り文字を入力させない/入力されたら除去する/入力欄自体を分割する)という話もあるが、本質ではないので割愛。

推測されにくいパターン

以下のパターンは生成時に弾く:

  • 同一文字3連続aaa, 111, AAA など
  • 連番3文字123, abc, ABC など(逆順も)

これらは人間が「ありそう」と思って試すパターンなので、含まないほうがいい。

また、3文字種混在を必須にする(数字・小文字・大文字が各1文字以上)と、さらに推測が困難になる。

重複チェック

同じコードを複数人に配布するのは致命的。生成時にHashSetなどで重複排除し、過去に発行したコードもファイルやDBで管理して照合する。

' VB.NETの例:HashSetで重複排除
If hSet.Add(generatedCode) Then
    ' 新規コード、採用
Else
    ' 重複、再生成
End If

検証側の設計

DB登録

発行したコードは事前にDBへ全件登録しておく。テーブル設計は:

CREATE TABLE serial_codes (
    id INT AUTO_INCREMENT PRIMARY KEY,  -- 発行数がすぐわかる
    code VARCHAR(14) UNIQUE NOT NULL,   -- UNIQUE制約で二重チェック
    used_at DATETIME NULL,
    used_by_ip VARCHAR(45) NULL,
    created_at DATETIME NOT NULL
);

idをAUTO_INCREMENTにしておくと、万単位で発行したときに「今何件目?」がすぐわかる。追加発行が発生したときも「ID 10001以降が2次発行分」のように区別できて便利。

使用済み管理

物理削除ではなく論理削除used_atに日時を記録)にする理由:

  • いつ使われたかの記録が残る
  • 不正調査時に履歴を追える
  • 「使用済みです」と正確にエラー表示できる
  • 追加発行時に過去のコードを誤って入れようとしてもUNIQUE制約で弾ける(人間はどこでミスるかわからない)

アクセスログ

コード入力時のIPアドレスや日時を記録しておくと、異常検知に使える。

短時間に同一IPから大量入力 → ランダムアタックの可能性

一定閾値を超えたらそのIPからの入力を一時ブロックする。

認証併用の是非

シリアルコードだけ入力させるか、ログイン必須にするか。

方式メリットデメリット
コードのみユーザーの手間が少ないアクセスログが取りにくい
ログイン併用誰が使ったか追跡可能、IP制限しやすいユーザー離脱の可能性

不正対策を重視するならログイン併用。ただし、封入特典の投票は「気軽に参加してほしい」ケースも多いので、ターゲット層を考えて決める。

個人的には認証 + シリアルコードの組み合わせがおすすめ。かなり正確にユーザーを追跡できるので、不正検知の精度が格段に上がる。

共通のセキュリティ観点

入力フォームのバリデーション

最重要。フロントエンドとバックエンド両方でチェックする。

  • 文字種制限(英数字のみ、など)
  • 桁数制限
  • SQLインジェクション対策(プレースホルダ使用)
  • HTMLエスケープ

フロントだけのチェックは開発者ツールで簡単に突破されるので、バックエンド側のバリデーションを怠らない。

レートリミット

短時間に大量のリクエストを送れないようにする。

  • 同一IPから1分間にN回まで
  • 同一セッションから1分間にN回まで
  • 超過したら一時ブロック or CAPTCHA表示

reCAPTCHA

投票フォームにreCAPTCHAをつける選択肢もあるが、意外とつけていないサイトが多い。

理由は単純で、人間がやってもロボット判定されることがあるから。特にCloudflare Turnstileは割と誤判定が多く、ユーザーにストレスを与える。Googleのやつも画像選択が面倒。

投票イベントで「投票したいのにできない」はクレームに直結するので、採用には慎重になる。ただ、bot対策としては効果的なので、トレードオフを理解した上で導入するのはあり。

CORS / XSS対策

  • CORSは適切なオリジンのみ許可
  • ユーザー入力を表示する際は必ずエスケープ
  • Content Security Policyヘッダーの設定

ただし、CORSだけでは外部サイトにフォームを作って直接POSTしてくるパターンは防げない。対策としては:

  • CSRFトークンを必須にする
  • 前述のワンタイムトークンを組み合わせる
  • リクエストボディをJSON形式に限定する(HTMLフォームはJSONを直接送れない)
  • ページ埋め込みトークンに有効期限を設ける(開きっぱなし対策)

最後の「ページ開きっぱなし対策」は意外と有効。JSで制御している連続投票防止を突破される可能性があるので、一定時間経過したら「ページを更新してください」と促す。CSRFやワンタイムトークンと併用すると冗長ではあるが、多層防御として機能する。

複合的に対策を組み合わせると効果的。

UIでの工夫

キャラ投票なら、UIの工夫で不正対策とUX向上を両立できる。

例:キャラ直接クリック + 音声演出

  1. シリアルコード入力後、投票対象のキャラをクリック
  2. キャラの声で「本当に投票する?」的なconfirm
  3. 確定したら「ありがとう!」的なボイス再生

チェックボックス+投票ボタンより直感的だし、ファンはボイスを聞きたいので自然と投票間隔が空く。結果的に連続投票の抑止にもなる。

まとめ

投票システムの本質は投票権の管理にある。

  • 時間制限は手軽だが穴が多い
  • SNS認証は複垢を完全には防げない
  • シリアルコードは発行・検証・異常検知の設計が必要

完璧な不正防止は無理。コストとのバランスで、どこまでやるか決める。

昔はスマホのデバイス固有値で端末識別できたが、今はiOSもAndroidもユーザー許可なしでは取得できない。コンバージョン計測すら難しい時代だ。複数端末、PC、回線変更など、いたちごっこは終わらない。

重要なのは「何を守りたいか」を明確にすること。公平性を重視するのか、参加者数を最大化したいのか。目的によって最適な設計は変わる。