投票システムの本質は「投票権」の設計にある
キャラ投票システムを何度も作ってきた。投票自体は「誰に票を入れるか選んで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を組み合わせる手がある。
フロー:
- サイトアクセス時にサーバーからJWT発行(未投票状態)→ LocalStorageに保存
- 投票時にJWTを送信 → サーバーで署名検証 → 投票処理 → 投票済みJWTを再発行
- 次回投票時は「投票済み」JWTなので弾く
// JWTのペイロード例
{
"voted": false, // 投票後はtrue
"lastVotedAt": null, // 投票後に日時が入る
"exp": 1736661600 // 有効期限
}
ポイントは「投票前にJWTが必要」という点。
LocalStorageを消しても、新しいJWT取得→投票という2往復の通信が必要になる。単にLocalStorageを消すだけでは投票できない。
メリット:
- 署名検証で改ざん検知できる
- サーバー側でDB参照せずに有効性を判定可能
- 有効期限をトークン自体に埋め込める
- 投票履歴もJWTに含められる(「さっき投票したでしょ?」の表示に使える)
SessionStorageでも同じことはできるが、LocalStorageなら投票履歴が残るので、UX的に「前回いつ投票したか」を表示できる利点がある。
ただし、これも自動化スクリプトを書かれたら突破される。「手動で消すのが面倒」という心理的障壁を上げる程度と割り切る。
ワンタイムトークンで連続投票防止
同じJWTの仕組みで、有効期限を超短く(数秒〜数十秒)設定するパターンもある。
フロー:
- 投票ページ表示時にワンタイムトークン発行
- 投票時にトークン送信 → 検証 → 投票処理 → トークン無効化
- 次の投票には新しいトークンが必要
これは「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-z、A-Z、0-9(62種)
記号を入れると組み合わせは増えるが、キーボードでもスマホでも入力が面倒になる。セキュリティのために面倒さを上げすぎるのは本末転倒なので、英数字のみがおすすめ。
14桁の場合、組み合わせは約 1.2×10^25通り になる。
日本語で書くと 約12𥝱(じょ)通り 。
仮に1秒間に100万回試行できても、全探索には約3800億年かかる計算。現実的にランダムアタックは不可能。
クライアントへの説明で「12𥝱通りあるので大丈夫です」と言うと、だいたいウケる。
本当は、人間がパッと見で視認できる文字数は7文字程度が限界という話があり、UXを考えれば6〜7文字にしたい。ただ、そんな性善説では簡単に突破されてしまう。14桁あればまず安心。
紛らわしい文字(0とO、1とl)を除外するオプションも有効。印刷物での視認性が上がり、入力ミスによる問い合わせが減る。
なお、印刷時に 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向上を両立できる。
例:キャラ直接クリック + 音声演出
- シリアルコード入力後、投票対象のキャラをクリック
- キャラの声で「本当に投票する?」的なconfirm
- 確定したら「ありがとう!」的なボイス再生
チェックボックス+投票ボタンより直感的だし、ファンはボイスを聞きたいので自然と投票間隔が空く。結果的に連続投票の抑止にもなる。
まとめ
投票システムの本質は投票権の管理にある。
- 時間制限は手軽だが穴が多い
- SNS認証は複垢を完全には防げない
- シリアルコードは発行・検証・異常検知の設計が必要
完璧な不正防止は無理。コストとのバランスで、どこまでやるか決める。
昔はスマホのデバイス固有値で端末識別できたが、今はiOSもAndroidもユーザー許可なしでは取得できない。コンバージョン計測すら難しい時代だ。複数端末、PC、回線変更など、いたちごっこは終わらない。
重要なのは「何を守りたいか」を明確にすること。公平性を重視するのか、参加者数を最大化したいのか。目的によって最適な設計は変わる。