個人ブログの問い合わせフォームをスパムから守りたい。でも、そのためだけに全訪問者のふるまいを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の完全インメモリで、/dataはemptyDir。同じ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停止時に「検証がスキップされて素通り」になるのが一番まずいので、迷ったら閉じる側に倒します(fetchやjson()が例外を投げるケースをtry/catchで拾い、タイムアウトも入れておくと、利用者向けのエラー制御もしやすくなります)。もう1つは、検証先をインターネット越しの公開URLではなくクラスタ内Service(cap-svc)**にしていること。ブログのDeploymentでは、ブラウザに渡す公開URL(CAP_PUBLIC_URL)とは別に、サーバ検証用のURL(CAP_VERIFY_URL)をクラスタ内Serviceへ明示的に向けています。こうすると、公開経路と検証経路の入口を分けつつ、検証はTLSも外向き通信も介さず、速く経路も短くなります。
動作確認――「アップロード成功≠復旧可能」と同じ発想で
seederが正しいキーを入れられているか、/siteverifyが期待どおり通る/弾くかは、ブラウザを開く前にクラスタ内から確認できます。ダッシュボードやログを目視するだけでなく、secret keyの照合まで一気通貫で叩くのが要点です。
Capの/siteverifyは、まずresponseがsiteKey:id:solutionのようにコロンで区切られた3要素になっているかを確認します。ここが欠けていると、secretの照合まで進まず400 Missing required parametersになるので、**ダミートークンも本物と同じ形(3要素)**にしておくのが要点です。SKとSECRETは環境変数に入れ、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_KEYをPod起動時に環境変数から読み、bcryptハッシュを1回だけ計算してキャッシュします。環境変数は稼働中のPodにはホットリロードされないので、OpenBaoの値を更新しても、走り続けているseederは古いsecretのハッシュを持ったままです。この状態でkey:<siteKey>を削除しても、古いハッシュが再投入されるだけです。
正しいrotate手順は次のとおりです。
- OpenBao側の
CAP_SECRET_KEYを新しい値に更新する(External Secrets Operatorがcap-secretを更新する)。 cap-valkeyのPodを再起動する。新しいseederが更新後のsecretを読み、新しいハッシュを計算して再seedする。- ブログ側の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のフィールド権限といった、製品紹介には出てこない現実のつまずきがひと通りありました。同じように「個人サイトの問い合わせを、外部に預けずに守りたい」人の参考になればと思います。
次に読む記事
- 実際に試したセルフホストソフトウェア一覧【2026年版】 ― Capを含め、どんなOSSを試して何が残ったかの一覧。
- クラスタ内Gitの落とし穴|k3s GitOpsを全損から復旧する設計 ― 今回のような小さなサービスも、Gitとクラスタで管理・復旧する土台の話。
- バックアップして終わりにしない――セルフホスト環境の復元テスト入門 ― 「起動した≠復旧できた」を確かめる考え方は、CAPTCHAの検証テストにも通じる。