reCAPTCHAをやめて、問い合わせフォームをセルフホストCAPTCHA(Cap)にした
2026-07-13
reCAPTCHAをやめて、問い合わせフォームをセルフホストCAPTCHA(Cap)にしたのカバー画像

個人ブログの問い合わせフォームをスパムから守りたい。でも、そのためだけに全訪問者のふるまいをGoogleに渡すのは気が進まない――そう思っている人向けの記事です。

このブログ(notes.midnightstops.com)の問い合わせフォームは、長らくGoogle reCAPTCHAを使っていました。これを、セルフホストのプライバシー重視CAPTCHA「Cap」に置き換え、自分の個人k3sクラスタで動かしています。置き換えの本命は「Googleをやめる」こと自体ではなく、以前のreCAPTCHA実装で抜けていたサーバ側検証を、設定漏れなら必ず弾く(fail-closed)形で入れ直したことでした。reCAPTCHA自体にもサーバ検証(siteverify)はあります。抜けていたのは、あくまで私の実装のほうでした。

この記事では、なぜ乗り換えたか、Capをk3sでどう動かしたか、そして「CAPTCHAは解けるのに送信ボタンが出ない」といった実際のハマりどころを、設計判断とあわせて残します。特定のnode名やSecretの値には触れません。

記事確認日: 2026年7月13日

先に結論

  • reCAPTCHAで気になっていたのは2点。Googleへの依存と、サーバ側でトークンを検証していなかったこと。後者のほうが実害としては大きい。
  • 代替として、reCAPTCHAのsiteverify相当のサーバ検証APIを持つセルフホストCAPTCHA「Cap」を選んだ。Proof-of-Work方式で、外部トラッキングに依存しない。
  • k3s上では、Cap本体(Bun製)+Valkey(Redis互換)の2 Deploymentで動かした。永続ボリューム(PV)は持たせず、キーはValkeyに載せ、消えても自動で再投入する構成にした。
  • クライアント側だけでなく、Astroのサーバ(API route)でCapの/siteverifyを叩くようにした。site key / secret keyが設定されていなければ検証を通さない(fail-closed)。
  • 一番ハマったのは、CAPTCHAを解いてもフロントの送信ボタンが出ない問題と、Cap起動時に500になる問題。どちらも原因は「思っていた前提」と違うところにあった。

構築できたことと、日々スパムを弾き続けられていることは別物です。この記事は前者と、その過程で判断したことが中心です。

何に困っていたか――reCAPTCHAの2つの問題

問い合わせフォームは、放置するとbotの投稿で埋まります。だからCAPTCHA自体は必要でした。困っていたのはreCAPTCHAの中身です。

1つ目は、依存とプライバシー。 reCAPTCHAは、フォームを置いたページの訪問者に対してGoogleのスクリプトを読み込ませます。個人サイトの、月に数件あるかどうかの問い合わせのために、全読者のふるまいを外部へ渡す構図が気になっていました。このブログは「SaaSに任せていた道具を自分で動かしてみる」という方針で書いているので、問い合わせフォームだけGoogleに預けているのは筋が通りません。

2つ目は、これが本題ですが、サーバ側でトークンを検証していなかったこと。 以前の実装は、reCAPTCHAをクライアント側のゲート(解かないと送信ボタンを押させない)としてしか使っていませんでした。つまりブラウザのJavaScriptを迂回してAPIへ直接POSTすれば、CAPTCHAは素通りできる状態です。「CAPTCHAを置いている」ことと「サーバがそれを検証している」ことは別物で、後者が抜けていました。

乗り換えを、この2つ目を直す機会にしました。単にreCAPTCHAをCapに差し替えるのではなく、サーバ側検証を必須化するのが目的です。

選択肢と判断基準

比較したのは、おおむね次の方向性です。

方式 外部依存 サーバ検証 向いている場面
Google reCAPTCHA あり(Google) 可能(siteverify) 依存を許容できる一般的なサイト
Cloudflare Turnstile / hCaptcha あり(各社) 可能 reCAPTCHA代替。運用は楽だが外部依存は残る
ハニーポット等の自作対策 なし 自前 軽量。巧妙なbotには弱い
Cap(セルフホスト) なし(自分で運用) 可能(/siteverify) 依存を無くしたい・運用を引き受けられる人

判断基準にしたのは次の3つです。

  • 外部トラッキングに依存しないこと。 これはTurnstileやhCaptchaでは完全には満たせません(別会社に依存が移るだけ)。
  • サーバ側検証があること。 ハニーポット単体だと自前で作り込む必要があり、これを機に「トークンを発行してサーバで検証する」という素直な形にしたかった。
  • 自分のk3sに無理なく載ること。 重いミドルウェアや専用DBを増やしたくない。

Capは、ブラウザに軽い計算をさせるProof-of-Work(PoW)を中心にbotの自動化コストを上げる方式で、reCAPTCHAの/siteverifyに相当するサーバ検証APIを持ちます(クライアント側はwidgetの差し替えが必要)。3つの基準を満たすので採用しました。

補足を2つ。1つは、Cap StandaloneはPoWに加えてJavaScriptのInstrumentation Challengeも組み合わせられますが、私の構成ではseedするconfigで無効化し、PoWのみで運用しています。もう1つは、検証(/siteverify)とキー管理は自分のインフラで完結する一方、現状はwidget本体のスクリプトを公開CDN(jsDelivrの@cap.js/widget)から読み込んでいる点です。厳密には「外部依存ゼロ」ではないので、そこまで詰めるならwidgetとWASMを自サイトかCap Standaloneのasset serverから配信し、バージョンを固定します(後述のチェックリスト参照)。

向かない人の話も先に。 攻撃されて困るのが問い合わせフォームではなく、ログインやチケット購入のような高価値の入口なら、運用をまるごと自分で背負うCapより、実績と運用サポートのあるマネージドサービスのほうが妥当です。Capは「個人サイトの問い合わせを、外部に預けずそこそこ守る」用途に向いています。

自分の構成――PVを持たないCap

k3s上の構成はシンプルです。

        (公開経路)                              (クラスタ内経路)
[ブラウザ] --widget/PoW--> Traefik(TLS)      [Astro SSR] --siteverify(secret付き)
  siteKey は公開・PoW を解く    │                     │
                               └──────┬──────────────┘
                                      ▼
                             cap-svc(同じ Service に着地)
                                      │
                                  cap (Bun)  ── widget API / dashboard / /siteverify
                                      │ REDIS
                              cap-valkey(外部非公開・seeder 同居)
                                siteKey/secretKey/チャレンジを保持

公開経路(ブラウザ)と内部検証経路(Astroサーバ)は、入口は別でも最終的に同じ cap-svc → cap Podに着地します。widgetの読み込みとPoWチャレンジは公開URL(cap.midnightstops.com、Traefik + TLS)経由、サーバ側の/siteverifyはクラスタ内Service経由という違いだけで、backendは同じです。Valkeyはさらにその背後にいて、どちらの経路からも直接は触れません。ブラウザに渡るのは公開値のsiteKeyとチャレンジだけで、secret keyとValkeyは外へ出ません。

  • cap Deployment: Cap Standalone(Bunランタイム)。ポート3000で、widget API・ダッシュボード・/siteverifyを提供します。問い合わせページからwidgetを読めるよう、CORS_ORIGINをこのブログのオリジンに限定しています。ただしCORSはブラウザに効く制約で、curlやbotが直接APIを叩くのは防げません。bot対策の本体はあくまでサーバ側検証で、CORSは「別originのブラウザからの誤用を抑える」程度に考えています。なお、公開ホスト(cap.midnightstops.com)はパス全体をこのcapに通しているため、widgetだけでなくダッシュボードや/swaggerも同じホストに現れます。ダッシュボードはADMIN_KEYで保護されますが、露出を絞りたければ別途IP制限やSSO、別Ingressで囲うのが安全です。
  • cap-valkey Deployment: Valkey(Redis互換)。Capはすべてのデータ(siteKey / secretKey / チャレンジ)をここに置く設計で、外部DBは要りません。

ここで一つ設計判断をしました。このValkeyにPVを付けていません。

CapにはsiteKey/secretKeyを環境変数で固定する仕組みが無く、キーはValkeyのkey:<siteKey>というレコードとしてのみ存在します。素直にやるなら「Valkeyに永続ディスクを付けて、ダッシュボードで発行したキーを保存する」ことになりますが、それだと永続化・バックアップ・復元の対象が1つ増えます(このクラスタでValkeyに付ける候補はlocal-pathのPVで、その場合はノードにも固定されます)。

代わりに選んだのは、キーを1回だけ人手で決めて秘密管理(OpenBao)に保存し、Valkeyには毎回同じキーを冪等に投入し直す方式です。Valkeyは--save "" --appendonly noの完全インメモリで、/dataemptyDir。同じPod内に「seeder」というサイドカーを置き、key:<siteKey>が無いときだけキーを再投入します。Valkeyが飛んでもseederが同じキーを入れ直すので、ブログ側から見たsite key / secret keyは不変・ゼロタッチになります。

ただし、この構成で自動復旧するのはseed対象にしたsite keyの検証機能だけです。CapはsiteKeyの設定以外にも、発行済みトークン、リプレイ防止の記録、ダッシュボードの統計、(使う場合の)ブロックルールや追加のsite keyなど、多くの状態をValkeyに持ちます。問い合わせフォームの用途では、チャレンジや発行済みトークンは短命なので消失を許容していますが、「Valkeyが飛んでも全部元通り」ではなく、seederで復元していない状態は失われる、と理解しておく必要があります。

秘密は、raw Secretでもダッシュボード発行でもなく、OpenBaoに置いてExternal Secrets Operator経由でcap-secretとしてクラスタに展開しています。ここには「ダッシュボードのログインパスワード」「固定site key」「固定secret key(平文)」「チャレンジ署名用の固定文字列」を入れています。値そのものはこの記事では出しません。

補足: emptyDirはノードローカルの一時領域です。Pod再配置で消えますが、seederが入れ直す前提なので許容しています。「ダッシュボードで手動rotateした結果が、Valkey再起動でseed値に戻る」という副作用はあるので、それが困るなら素直にPVを付けてseederを外す従来案でも構いません。

サーバ側検証をfail-closedで入れる

これが乗り換えの本命です。フロント(Svelte)はwidgetを表示してトークンを受け取るだけで、信頼はしません。Astroのサーバ側API routeで、受け取ったトークンをCapの/siteverifyに投げて検証します。

// Astro API route(要点のみ)。siteKeyはpublic、secretはサーバ専用。
async function verifyCap(token: string): Promise<boolean> {
  if (!CAP_SITE_KEY || !CAP_SECRET_KEY || !token) {
    // 設定漏れ・トークン無しは fail-closed(検証を通さない)
    return false;
  }
  try {
    const res = await fetch(`${CAP_VERIFY_URL}/${CAP_SITE_KEY}/siteverify`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ secret: CAP_SECRET_KEY, response: token }),
      // Capに到達できないときにAPI全体を巻き込まないよう、タイムアウトも付ける
      signal: AbortSignal.timeout(5_000),
    });
    if (!res.ok) return false;
    const body = await res.json().catch(() => null);
    return body?.success === true;
  } catch {
    // 接続不可・タイムアウト・非JSON応答も検証を通さない
    return false;
  }
}

ポイントは2つ。1つは、CAP_SITE_KEYまたはCAP_SECRET_KEYが未設定、あるいはCapに到達できない場合は**検証を通さない(fail-closed)こと。設定ミスやCap停止時に「検証がスキップされて素通り」になるのが一番まずいので、迷ったら閉じる側に倒します(fetchjson()が例外を投げるケースをtry/catchで拾い、タイムアウトも入れておくと、利用者向けのエラー制御もしやすくなります)。もう1つは、検証先をインターネット越しの公開URLではなくクラスタ内Service(cap-svc)**にしていること。ブログのDeploymentでは、ブラウザに渡す公開URL(CAP_PUBLIC_URL)とは別に、サーバ検証用のURL(CAP_VERIFY_URL)をクラスタ内Serviceへ明示的に向けています。こうすると、公開経路と検証経路の入口を分けつつ、検証はTLSも外向き通信も介さず、速く経路も短くなります。

動作確認――「アップロード成功≠復旧可能」と同じ発想で

seederが正しいキーを入れられているか、/siteverifyが期待どおり通る/弾くかは、ブラウザを開く前にクラスタ内から確認できます。ダッシュボードやログを目視するだけでなく、secret keyの照合まで一気通貫で叩くのが要点です。

Capの/siteverifyは、まずresponsesiteKey:id:solutionのようにコロンで区切られた3要素になっているかを確認します。ここが欠けていると、secretの照合まで進まず400 Missing required parametersになるので、**ダミートークンも本物と同じ形(3要素)**にしておくのが要点です。SKSECRETは環境変数に入れ、secretをコマンド履歴へ直書きしないようにします。

# クラスタ内から。ingress や証明書が無くても検証できる。
# SK / SECRET は Secret から取り出して環境変数に入れておく(履歴に平文を残さない)。
DUMMY="${SK}:a:b"   # siteKey:id:solution の形。存在しないので消費もされない。

# 1) チャレンジ発行(seedしたjwtSecret/configが正しいか) -> 200
curl -s -X POST "http://cap-svc.cap.svc.cluster.local:3000/${SK}/challenge" -o /dev/null -w '%{http_code}\n'
# 2) 誤ったsecret + 形式は正しいトークン -> 403(secret照合で弾ける)
curl -s -X POST "http://cap-svc.cap.svc.cluster.local:3000/${SK}/siteverify" \
  -H 'Content-Type: application/json' -d "{\"secret\":\"wrong\",\"response\":\"${DUMMY}\"}"   # 期待 403
# 3) 正しいsecret + 存在しないトークン -> 404 Token not found
#    (=secretHashの照合はOK。ここまで通ればseedが完全一致している証拠)
curl -s -X POST "http://cap-svc.cap.svc.cluster.local:3000/${SK}/siteverify" \
  -H 'Content-Type: application/json' -d "{\"secret\":\"${SECRET}\",\"response\":\"${DUMMY}\"}"  # 期待 404

3番目が「正しいsecretなのに403ではなく404」まで進めば、seederが投入したハッシュとsecret keyが一致している、と確信できます。逆に400が返るなら、トークンの形式(3要素になっているか)を先に疑います。バックアップの記事で「アップロードが成功しても復元できるとは限らない」と書きましたが、CAPTCHAも同じで、「Podが起動している」ことと「検証が正しく通る」ことは別に確かめる必要があります。

失敗しやすい点と復旧

ここが、実際に手を動かした人にしか書けない部分です。順に、原因と対処を残します。

1. Capが起動直後に500(PASSWORD_INVALID_ENCODING)

最初、secret keyのbcryptハッシュを外部ツールで事前計算してseedしようとして、CapがPASSWORD_INVALID_ENCODINGで500を返しました。原因は、別ツールで作ったハッシュ(argon2など)がCap側の検証と互換でなかったこと。

対処は、seederをCap本体と同じBunイメージにして、ハッシュを実行時にBun.password.hash(secret, "bcrypt")で生成すること。同じランタイムで作ればBun.password.verifyと必ず一致し、ハッシュを事前計算して持ち回る必要がなくなります。「ハッシュは外で作って埋め込む」という素朴な発想が、ここでは裏目に出ました。

2. CAPTCHAは解けるのに、送信ボタンが出ない

フロントで一番ハマったのがこれです。Cap widgetは<cap-widget>というカスタム要素(Web Component)で、解き終わるとsolveイベント(CustomEvent)を発火します。ところが私のAstro+Svelte 5の構成では、on:solveを使った実装だとトークンの受け取りが安定せず、「解いたのにトークンが取れず、送信ボタンが現れない」状態になりました。これをSvelte 5全般の仕様と断定はできませんが、要素参照を取って直接購読する形に変えると解消しました。

対処は、on:ディレクティブに頼らず、bind:thisで要素の参照を取り、addEventListener('solve', ...)直接購読することです。コンポーネント破棄時にはリスナーを外しておくと、より丁寧です。

<script lang="ts">
  let capWidget: any;                 // <cap-widget> への参照
  onMount(() => {
    capWidget?.addEventListener('solve', onCapSolve);   // 直接購読で取りこぼしを防ぐ
    capWidget?.addEventListener('reset', onCapReset);
    capWidget?.addEventListener('error', onCapError);
    // 破棄時にリスナーを外す
    return () => {
      capWidget?.removeEventListener('solve', onCapSolve);
      capWidget?.removeEventListener('reset', onCapReset);
      capWidget?.removeEventListener('error', onCapError);
    };
  });
</script>

<!-- 認証後もwidgetをunmountせず、CSSで隠すだけにする。 -->
<cap-widget bind:this={capWidget}
  data-cap-api-endpoint={capApiEndpoint}
  style={$captchaVerified ? 'display:none' : ''}></cap-widget>

もう一つ関連するのが、認証後にwidgetをDOMから外さないこと。{#if}でwidgetごと消すと、addEventListenerで付けたリスナーも一緒に失われ、トークン失効後の再試行で再びsolveを取りこぼします。そこで、認証後も要素は残したままdisplay:noneで隠すだけにしました。

3. 送信が必ず404になる(末尾スラッシュ)

このブログのAstroはtrailingSlash: "always"設定です。フロントから/api/contact(スラッシュ無し)にPOSTすると404になり、送信が必ず失敗しました。/api/contact/と末尾スラッシュ付きで叩くようにして解決。CAPTCHA自体とは無関係ですが、乗り換え作業中に踏んだので残します。

4. 検証は通るのに、保存で403

サーバ検証を通した後、CMS(Directus)へ保存する段で403になりました。原因は、フォームのデータにCAPTCHA関連のフィールドを含めて送っていたこと。保存先のコレクションにそのフィールドが無い(あってもアプリの権限外)と、「permission to access field」で保存全体が失敗します。検証はサーバ側で完結させ、CMSにはCAPTCHAの値を渡さない――役割を分けることで解決しました。

ここで一つ大事な挙動があります。Capの検証トークンは**/siteverifyで消費(使い捨て)**され、一度検証したトークンはValkeyから消えます。したがって、CAPTCHA検証は通ったのにDirectus保存など後段の処理で失敗したとき、**同じトークンで再送すると2回目は404 Token not found**になります。失敗時はトークンを使い回さず、widgetをresetして新しいトークンを取り直す必要があります。このブログのフロントも、送信失敗時にwidgetをresetして再取得させています。

復旧の考え方

壊れたときの自動復旧と、キーを意図的に変えるrotateは、分けて考える必要があります。

Valkeyが飛んだ(コンテナ再起動やPod再配置でインメモリのデータが消えた)場合は、seederがkey:<siteKey>の不在を検知して、同じキーを入れ直します。secret keyは変わらないので、CAPTCHA機能としてはそのまま自動復旧します。ここは何もしなくて構いません。

一方、secret keyをrotateしたいときは、Valkeyのキーを消すだけでは足りません。seederはCAP_SECRET_KEYPod起動時に環境変数から読み、bcryptハッシュを1回だけ計算してキャッシュします。環境変数は稼働中のPodにはホットリロードされないので、OpenBaoの値を更新しても、走り続けているseederは古いsecretのハッシュを持ったままです。この状態でkey:<siteKey>を削除しても、古いハッシュが再投入されるだけです。

正しいrotate手順は次のとおりです。

  1. OpenBao側のCAP_SECRET_KEYを新しい値に更新する(External Secrets Operatorがcap-secretを更新する)。
  2. cap-valkeyのPodを再起動する。新しいseederが更新後のsecretを読み、新しいハッシュを計算して再seedする。
  3. ブログ側のPodも再起動する。/siteverifyに送るsecretを新しい値に合わせる(このPodも起動時にenvを読むため)。

つまり「消せば直る」のは障害復旧の話で、rotateはenvを読み直させるためのPod再起動込みで考える、という切り分けです。

向いている人・向かない人

向いている人

  • 個人サイトやブログの問い合わせフォームを、外部トラッキングに頼らず守りたい人。
  • すでにk3sやDocker Composeで何かを動かしていて、小さなサービスをもう1つ運用に足せる人。
  • 「CAPTCHAを置く」だけでなく「サーバで検証する」まで自分で入れたい人。

向かない人

  • 運用を増やしたくない人。widgetを貼るだけで完了するマネージドサービスのほうが早い。
  • ログインや決済など、破られたときの損失が大きい入口を守りたい人。実績と運用体制のある選択肢を優先すべき。
  • Web Componentやサーバ側検証の切り分けに時間をかけられない人。上記のハマりどころは、それなりにデバッグが要る。

まとめ

reCAPTCHAからCapへの乗り換えは、「Googleをやめる」こと自体より、その機会にサーバ側検証をfail-closedで入れ直したことに意味がありました。Capはk3s上でPVも外部DBも増やさずに動き、キーはinメモリ+seederで固定できます。ただし、起動時のハッシュ互換、Svelte 5のカスタムイベント、末尾スラッシュ、CMSのフィールド権限といった、製品紹介には出てこない現実のつまずきがひと通りありました。同じように「個人サイトの問い合わせを、外部に預けずに守りたい」人の参考になればと思います。

次に読む記事

reCAPTCHAをやめて、問い合わせフォームをセルフホストCAPTCHA(Cap)にした
http://notes.midnightstops.com/posts/34/
作者
kairo
公開日
2026-07-13