コンテンツにスキップ

Claude CodeでWebサイトにBasic認証をかけて合言葉を知っている人だけに見せる

公開したサイトを、全員には見せたくないことがある。作りかけを関係者だけに見せたい。身内向けのページを作った。管理用の画面を自分だけが開けるようにしたい。

こういうとき、合言葉をひとつ決めて、知っている人だけを通すのがいちばん手軽。Basic認証という、ブラウザに昔から備わっている仕組みを使う。

この記事では2ページだけの小さなサイトを作り、サイト全体にかける場合一部だけにかける場合の両方を試す。

ログイン機能というと大がかりに聞こえるが、Basic認証は画面を作らない。ログインフォームを実装する必要も、パスワードをデータベースに保存する必要もない。ブラウザが合言葉を尋ねる画面を出してくれるので、こちらは「合っているか照合する」だけでよい。

そのぶん割り切りもある。誰がアクセスしたかは区別できないし、ログアウトもできない。それでも「関係者だけに見せる」用途にはこれで足りることが多い。もっと本格的な守り方が要るようになったときの選択肢は、6章で案内する。

必要な前提は「Cloudflareにサイトを公開できること」だけ。データベースも Git も使わない。

2ページだけのサイトを作る。

public/
├─ index.html 誰でも見られるトップ
└─ secret/index.html 合言葉を知っている人だけ

トップページは誰でも開ける。

「だれでも見られるページ」と書かれたシンプルなページ

/secret/ を開こうとすると、ブラウザが合言葉を尋ねてくる。

ブラウザが表示するログインダイアログ。ユーザー名とパスワードの入力欄がある

正しく入れると中身が見える。

「合言葉を通過しました」と表示された鍵のかかったページ

このダイアログは自分で作るものではない。 ブラウザが勝手に出す。だから HTML 側の実装は要らない。

まずはサイトを作る。ここはCloudflareにHTMLをアップロードして公開すると同じ話なので、さっと済ませる。

Finder で作業用のフォルダを新しく作る。たとえば ホームclaudemy-secret-site

次に Claudeデスクトップアプリを起動し、Code(Claude Code)を選択 → 新規 → いま作ったフォルダを指定する(~/claude/my-secret-site)。

そこで次のように頼む。

Claude
このフォルダに public フォルダを作って、2ページだけの静的サイトを作ってください。
- public/index.html は誰でも見られるトップページ
- public/secret/index.html は、あとで合言葉で守るページ
- 中身は見分けがつけばよく、凝らなくていい
- トップページから secret のページへのリンクを置いてください

途中でファイル操作の許可を求められたら、その都度許可する。

できあがったら public/index.html をダブルクリックして、ブラウザで表示を確認しておく。この時点ではどちらも普通のページで、鍵はかかっていない。

鍵をかける前に、まず普通のサイトとして公開する。この段階では2ページとも誰でも見られる。そこから鍵をかけるので、何が変わったのかがはっきり分かる。

CloudflareにHTMLをアップロードして公開すると同じ手順で、public フォルダをそのままアップロードする。

  • CloudflareのダッシュボードComputeWorkers & Pages
  • Create applicationGet startedDrag and drop your filesGet started
  • Project name に好きな名前を入れて Create project
  • public フォルダをドラッグ&ドロップして Deploy site

プロジェクト名はそのまま公開URL(https://名前.pages.dev)になる。この記事では my-secret-site として書き進めるが、そのまま使うと他の読者と衝突するので、自分で決めた名前に読み替えてほしい。

公開できたら https://my-secret-site.pages.dev を開いてみる。この時点では /secret/ も誰でも見られる。 ここに次の章で鍵をかける。

Basic認証はブラウザに標準で備わっている認証の仕組み。保護されたページを開くと、ブラウザがユーザー名とパスワードを尋ねるダイアログを出す。入力した値はリクエストのたびにヘッダに載ってサーバーへ送られ、サーバー側で照合される。

sequenceDiagram
    participant B as ブラウザ
    participant CF as Cloudflare Pages
    B->>CF: /secret/ にアクセス
    CF-->>B: 401(認証してね)
    Note over B: ユーザー名とパスワードの<br>ダイアログを表示・入力
    B->>CF: 同じURLに再リクエスト<br>(ユーザー名とパスワードをヘッダに載せる)
    Note over CF: 預けてある合言葉と照合
    alt 一致
        CF-->>B: ページを返す(ここで初めて中身が見える)
    else 不一致
        CF-->>B: 401(ダイアログを再表示)
    end

ポイントは 401 という返事に WWW-Authenticate というヘッダを付けること。これを見たブラウザが、自動でダイアログを出してくれる。

合言葉を確かめる処理を作ってもらう。仕組み自体は短いので、どこに何を置くか比較のしかただけ伝えればよい。守りたいパスは最初に1回だけ言い、あとは「そのパス」として扱ってもらう。こうすると、範囲を変えたくなったとき直す場所は1か所でよい。

Claude
Basic 認証で特定のパス以下を守るミドルウェアを作って。守るパスは /secret/。
- ミドルウェアはルート直下(functions/_middleware.js)に置いてサイト全体で動かし、
守るパスは1つの定数 PROTECTED_PREFIX にまとめて、そのプレフィックス以下かどうかで判定する
- 先頭スラッシュを増やした抜け道も塞ぎたいので、連続したスラッシュを正規化してから判定する
- 合言葉は環境変数 SITE_PASSWORD から読む。ユーザー名は guest 固定でよい
- タイミング攻撃を避けるため、比較は crypto.subtle.timingSafeEqual を使う

守りたいパスが /secret/ という文字列は、プロンプトの中に1か所だけ/room/ に変えたいなら、そこを書き換えるだけでよい。複数のパスを守りたいときも同じで、「守るパスは /admin//api/admin/」のように並べて、定数を配列にして「どれかに当たるか」で判定してもらえばよい。

できあがるのは1ファイルだけ。この置き場所と範囲の決め方が大事だが、動かすところまで進めたいので、範囲の変え方は5章、置き場所の理由は7章に回す。

functions/_middleware.js

合言葉をコードに書いてはいけない。 ファイルにも書かない。Cloudflare に預ける。ブラウザで入れる。

  1. Cloudflare ダッシュボードWorkers & Pages → 自分のプロジェクト → Settings
  2. 上の Choose EnvironmentProduction になっていることを確かめる
  3. Variables and secrets+ Add
  4. TypeSecret に変える(既定は Text)。Variable nameSITE_PASSWORDValue に合言葉を入れる
  5. Save

変数を追加するダイアログ。Type で Secret を選んでいる

Type は既定で Text になっている。合言葉を入れるので Secret に変える

入力後の Variables and secrets。SITE_PASSWORD が Secret で入っている

入れたあと。値は伏せ字になり、画面からは読めない

入れるのは合言葉だけ。 ユーザー名は §4-2 で guest 固定にしてあるので、Cloudflare 側には何も入れない

guest 以外にしたいなら、コードのほうを変える。

Claude
Basic認証のユーザー名を taro に変えて

ユーザー名は秘密ではないので、コードに書いてかまわない。 相手に伝えて使ってもらうものなので、隠しても意味がない。人ごとに分けたいときは §5-1 で扱う。

預けるのは秘密だけでいい。 何でもかんでも厳重に扱おうとすると手間が増えて、結局どこかで手を抜くことになる。 秘密なのはどれかを見分けて、そこだけ守る。

4-4. 鍵をかけたあと、もう一度デプロイする

Section titled “4-4. 鍵をかけたあと、もう一度デプロイする”

ここから先は Wrangler を使う。 ブラウザのドラッグ&ドロップは functions/ を扱えないので、鍵をかけたサイトはブラウザからは公開できない。3章で作ったプロジェクトはそのまま使える。

Cloudflare にログインしていなければ済ませておく。

Terminal window
npx wrangler login

合言葉を預けただけでは反映されない。 もう一度デプロイして、はじめて有効になる。Cloudflare 側もそう言っている(変数を追加する画面に「This change will take effect on the next deployment.」と出る)。

Claude
public フォルダの中身を、Cloudflare Pages のプロジェクト「my-secret-site」に、本番(main ブランチ)としてデプロイして

コマンドで明示するなら npx wrangler pages deploy ./public --branch=main --project-name=my-secret-sitefunctions/ の中身は自動でまとめられる。

/secret/ を開くと合言葉を聞かれ、トップページは今までどおり誰でも開ける。

ここまでで、合言葉はひとつ、鍵をかける範囲は /secret/ 以下だけという形ができた。使い始めると、たいていこの3つが要る。どれも頼むだけで足りる。

いまは合言葉がひとつだけ。adminguest を分けたい、といったことはよくある。自分用と、人に渡す用。

環境変数に複数の組を並べて、順に照合すれば増やせる。

Claude
Basic認証のユーザーを複数にしたい。
環境変数 SITE_USERS を "ユーザー名:合言葉,ユーザー名:合言葉" の形式として読み、
どれかに一致したら通すようにして。
値は私が自分で Cloudflare に入れるので、コードにも設定ファイルにも書かないで。

値そのものはプロンプトに書かない。 合言葉が混じっているので、§4-3 と同じ手順でブラウザから入れる。TypeSecretVariable nameSITE_USERSValueadmin:合言葉1,guest:合言葉2 のように並べる。

入れたらデプロイし直す(§4-4 と同じで、足しただけでは反映されない)。

これで SITE_PASSWORD は使われなくなる。 ダッシュボードから消してよい。残っていても害はないが、どれが効いているのか分からなくなるので、消しておくほうがよい。

増やすと2つ、できることが増える。

  • 誰が入ったか分かる。 ユーザー名はリクエストのたびに送られてくるので、記録したければ記録できる
  • 個別に止められる。 環境変数から1行消してデプロイし直すだけ

鍵をかける範囲はコードの判定で決まる。変えたいときは、その判定を書き換えてもらう。

かける範囲やり方
一部だけ/secret/ 以下)パスが /secret/ 以下かを判定して、当たったものだけ合言葉を求める
サイト全体範囲の判定を外して、どのページにも合言葉を求める

サイト全体にかけたければ、これも頼めばよい。

Claude
Basic認証をサイト全体にかけたい。
パスの範囲判定を外して、どのページでも合言葉を求めるようにして

直してもらったらデプロイし直す。 コードを変えただけでは、公開中のサイトは変わらない。

実際に両方で確かめると、こうなる。

範囲/(トップ)/secret/
/secret/ 以下だけ誰でも見られる合言葉が要る
サイト全体合言葉が要る合言葉が要る

作りかけのサイトを丸ごと隠したいときは全体に、一部のページだけ隠したいときはパスで絞る。

5-3. 管理ページと、その API をまとめて守る

Section titled “5-3. 管理ページと、その API をまとめて守る”

守りたいパスが2つ以上あるときは、並べて言えばよい。たとえば管理画面 /admin/ と、その裏側の管理API /api/admin/ の両方。

Claude
Basic認証をかける範囲を /admin/ と /api/admin/ の2つにして

これもデプロイし直すまで、かかる範囲は変わらない。

この「管理ページ+その API」の組み合わせは相性がよい。管理者が /admin/ を開くと合言葉を1回入れる。以降、その画面が /api/admin/… を呼ぶときは、ブラウザが同じ合言葉を自動で付けるので通る。いっぽう、その画面を通さずに /api/admin/… を直接開こうとしても、合言葉が無いので 401(4-1 の「認証してね」)が返るだけ。範囲に入れていない公開API(/api/posts など)は、今までどおり誰でも読める。

ページだけに鍵をかけると、API 側が素通しのまま残る。 画面は守られているのに、その裏で動いている /api/admin/… を誰でも呼べる状態になり、そこから消したり書き換えたりできてしまう。ページと、そのページが呼ぶ API をセットで守るのが、破綻しない使い方。

Basic認証は「合言葉ひとつ・最小の手間」の入り口。関係者だけに見せる用途なら、ここまでで十分。ただ、使っていると不便も見えてくる。

ログアウトができない。 一度通すと、ブラウザを完全に終了するまで認証されたままになる。ログアウトボタンを付けようがない。

ログイン画面の見た目を変えられない。 あのダイアログはブラウザのもので、こちらからは手を出せない。

誰がアクセスしたかを区別できない。 合言葉を知っているかどうかだけなので、複数人に配ると誰が見たのか分からない。合言葉を変えるときも全員に配り直すことになる。

いっぽうで、思ったより便利な点もある。ブラウザのパスワードマネージャがそのまま使える。

Chrome が「パスワードを保存しますか?」と提案しているダイアログ

通過すると、ブラウザが保存を提案してくる。保存しておけば次からは自動で入るので、長くて複雑な合言葉にしても困らない。自前でログイン画面を作ると、パスワードマネージャに認識させるのに手間がかかることがあるが、Basic認証はブラウザ標準の仕組みなので、こちらは何もしなくていい。

用途が変わってきたら、別の方式が向いてくる。この記事では作らないが、見当をつけておくと選べるようになる。

Basic認証(この記事)トークン認証Cloudflare Access
実装・設定の手間最小少ない多い(初回のみ)
ログイン画面ブラウザ固定のダイアログ自前(自由にデザイン可)Google のログイン画面
ログアウト事実上できないできるできる
アクセス制限の単位合言葉を知っているか合言葉を知っているかメールアドレス単位
外部サービス不要不要Google Cloud Console が必要

合言葉方式の限界は「誰がアクセスしたか」を区別できないこと。自分ひとり、あるいは信頼できる少人数なら何の問題もないが、個人を識別したくなったら上に進むとよい。

ここまでで、Basic認証は一通り使える。次の7章は補足で、読まなくてもかまわない。

ここから先は補足。合言葉をかけて使うだけなら、6章までで足りている。この章で見るのは、ミドルウェアをなぜルート直下に置くのかなぜ比較の仕方まで指定するのかブラウザを開かずにコマンドだけで預ける方法なぜユーザー名を Cloudflare に置かないのかの4つ。

4章で作ったファイルは functions/_middleware.js だった。ルート直下に置いた _middleware.js は、サイトへの全リクエストに割り込む。 静的ファイルも含めて、すべてがいったんここを通る。

そのうえで、「どのパスに鍵をかけるか」はコードの中で判定する。作ってもらったコードには、範囲を決める1行が入っている。

const PROTECTED_PREFIX = '/secret/';

このプレフィックスに当たるパスだけ合言葉を求め、それ以外は素通しする。鍵をかける範囲を変えたいときは、コードのこの判定を変える(頼み方は 5-2)。

Cloudflare Pages には、範囲を指定するもう一つのやり方がある。 ミドルウェアを functions/secret/_middleware.js のようにディレクトリの中に置くと、その _middleware.js は名前のとおり「そのディレクトリ以下だけ」に割り込む。設定を書かず、置き場所だけで範囲が決まるので、一見こちらの方が素直に見える。

だが、この方式には抜け道がある。 先頭のスラッシュを1つ増やした //secret/ でアクセスすると、合言葉を聞かれないまま保護ページの中身が返ってしまう。実際に確かめると、こうなる。

アクセスするパス結果
/secret/合言葉が要る(401)
//secret/(スラッシュ2つ)素通りで中身が見える
///secret/素通りで中身が見える

原因は、Cloudflare Pages の内部で2つの処理がパスの見方を食い違わせていること。

  • ミドルウェアを動かすルーターは、//secret/ を「/secret/ 以下とは別のパス」と見なして、ミドルウェアを動かさない
  • 静的ファイルの配信は、//secret//secret/index.html解決して中身を返す

つまり、鍵をかけたはずの secret/index.html が、ガードを通らないまま //secret/ から読めてしまう。これはこの作例だけの不具合ではなく、「ディレクトリに置いて範囲を絞る」という方式そのものに空く穴。 範囲の判定は、先頭スラッシュ1つですり抜けられる。

ルート直下に置けば、この穴は塞げる。 ルート直下の functions/_middleware.js は全リクエストで動くので、//secret/ も取りこぼさない。あとは判定の前にパスを正規化する、つまり連続したスラッシュを1つにまとめてから /secret/ かどうかを見れば、//secret////secret/ も同じ /secret/ として扱える。作ってもらったコードには、この正規化が入っている。

const rawPath = new URL(request.url).pathname;
const path = rawPath.replace(/\/{2,}/g, '/');

修正した作例で確かめると、//secret////secret/ も 401 になり、素通りできなくなる。「全体で動かして、正規化したパスで判定する」ことで、ディレクトリスコープの抜け道が塞がる。

7-2. なぜ比較の仕方まで指定するのか

Section titled “7-2. なぜ比較の仕方まで指定するのか”

4-2 のプロンプトには、こんな1行を入れていた。

- タイミング攻撃を避けるため、比較は crypto.subtle.timingSafeEqual を使う

できあがったコードでは、ユーザー名も合言葉も、この関数を通して比べている。関数名や変数名は頼むたびに変わるが、形はだいたいこうなる。

const okUser = timingSafeEqual(user, 'guest');
const okPass = timingSafeEqual(pass, env.SITE_PASSWORD);

文字列を先頭から順に比べると、合っている文字数が多いほど時間がかかる。この差を何万回も測られると、合言葉を1文字ずつ当てられてしまう。timingSafeEqual は常に同じ時間で比べるので、この手が使えなくなる。

ユーザー名と合言葉を両方とも必ず比較しているのも同じ理由。片方が違った時点で打ち切ると、「どちらが違ったか」が応答の速さから分かってしまう。

crypto.subtle.timingSafeEqual長さの同じデータどうししか比べられないので、そこをどう埋めるかで実装が分かれる。先に長さを確かめるやり方と、いったんハッシュにして長さを揃えるやり方があり、どちらが出てきても狙いは同じ。

4章ではブラウザで預けた。同じことはコマンドでもできる。 ターミナルで完結させたい場合や、ダッシュボードを開けない環境ではこちら。入力を待つので、自分でターミナルに打つ(Claude Code の中では入力できずに止まる)。Windows はコマンドプロンプト(cmd)を開く(→ Windowsでのセットアップ)。

Terminal window
npx wrangler pages secret put SITE_PASSWORD

実行するとその場で入力を求められる。貼り付けても画面に表示されず、ターミナルの履歴にも残らないのが、ブラウザで入れるより優れている点。

⚠️ npx が動かない環境ではここで止まる(Windows の PATH や実行ポリシー)。4章のブラウザ方式なら、どちらも関係ない。

7-4. なぜユーザー名を Cloudflare に置かないのか

Section titled “7-4. なぜユーザー名を Cloudflare に置かないのか”

4章では、ユーザー名をコードに書いて、Cloudflare には合言葉だけを預けた。理由は2つある。

1つは、隠す意味がないから。 ユーザー名は相手に伝えて使ってもらうもので、秘密ではない。秘密でないものを秘密の置き場所に入れても、手間が増えるだけ。

もう1つは、Cloudflare Pages の平文の変数(Text)に癖があるから。 秘密でない値を置くならこちらだが、2つとも踏むと厄介なので、本記事では使っていない。

この2つを避けたいので、本記事は平文の変数を使わない。 Cloudflare に置くのは Secret だけ、秘密でないものはコードに書く、という切り分けにしている。

8. 補足:Workers のサイトに鍵をかける

Section titled “8. 補足:Workers のサイトに鍵をかける”

ここまでは Cloudflare Pages の話。 手元のサイトが Cloudflare Workers*.workers.dev)で動いているなら、やり方が変わる。Wrangler で新しく作ったサイトは Workers になっていることが多い(→ Workers と Pages どちらを使うか)ので、確かめてから読み進めてほしい。

8-1. 🚨 いちばん大事なこと:設定を1つ書かないと、鍵が効かない

Section titled “8-1. 🚨 いちばん大事なこと:設定を1つ書かないと、鍵が効かない”

Workers は、静的ファイルがあればコードを通さずにそのまま返す。 これが既定。

つまり認証のコードを書いても、/secret/index.html のような静的ページは素通りで配信される。手元で試すと、コードを書いたのに /secret/ が 200 で中身を返した。認証は1度も実行されていない。

Pages との決定的な違い。 Pages の functions/_middleware.js はルート直下に置くだけで全リクエストに割り込むので、この事故が起きない。

塞ぐには wrangler.jsoncrun_worker_first を書く。

"assets": {
"directory": "./public",
"binding": "ASSETS",
"run_worker_first": ["/secret/*"]
}

公式の移行ガイドも「認証チェックをするならこの指定が必要」と名指ししている。true にすれば全リクエストが先にコードを通る。

4章の依頼文を Workers 向けにしたもの。run_worker_first を明示的に書かせるのが肝。

Claude
Basic 認証で特定のパス以下を守る処理を Worker に作って。守るパスは /secret/。
- Worker の fetch ハンドラで判定し、通ったら env.ASSETS.fetch(request) で静的ファイルを返す
- wrangler.jsonc の assets に run_worker_first を書いて、/secret/ 以下は必ずコードを通るようにする
- 守るパスは1つの定数にまとめて、そのプレフィックス以下かどうかで判定する
- 先頭スラッシュを増やした抜け道も塞ぎたいので、連続したスラッシュを正規化してから判定する
- 合言葉は環境変数 SITE_PASSWORD から読む。ユーザー名は guest 固定でよい
- タイミング攻撃を避けるため、比較は crypto.subtle.timingSafeEqual を使う

合言葉の預け方はコマンドが変わる。ダッシュボードから入れてもよいWorkers & Pages → 自分の Worker → SettingsVariables and Secrets)。

Terminal window
npx wrangler secret put SITE_PASSWORD

Pages と違って、預けた時点で反映される(デプロイし直さなくてよい)。4-4 の「もう一度デプロイ」は Workers では不要。

書いたつもりで効いていないのがいちばん怖いので、公開したら必ず確かめる。

Terminal window
curl -I https://自分のサイト/secret/

HTTP/2 401 が返れば効いている。 200 が返るなら run_worker_first が抜けている。ブラウザで開くとダイアログが出るので効いているように見えるが、ダイアログを出しているのはブラウザで、鍵がかかっている証拠にはならないcurl で確かめるのが確実。

8-4. Pages と Workers、どちらで鍵をかけるか

Section titled “8-4. Pages と Workers、どちらで鍵をかけるか”
PagesWorkers
割り込みの指定functions/_middleware.js を置くだけrun_worker_first が要る
書き忘れたとき範囲が狭くなるだけ🚨 鍵が無言で外れる
静的配信の料金無料・無制限鍵をかけたパスは無料枠の対象
合言葉の反映デプロイし直しが要る預けた時点で反映される

サイト全体に割り込ませたいなら Pages のほうが素直。 新しく作るなら本編(3章)のようにブラウザで Pages プロジェクトを作るのがよい。すでに Workers で動いているサイトに後付けするなら、この章のやり方になる。

  • 合言葉が漏れたら、ダッシュボードで SITE_PASSWORD を入れ直し、デプロイするだけでよい(→ §4-3)。単一の合言葉方式の数少ない利点
  • 合言葉を変えてもデプロイし直すまで反映されない
  • 掲示板などのデータを扱うアプリに管理画面を付けて守る例は、管理画面をBasic認証で守るにある。この記事の内容を、実際のアプリに当てはめた形になっている