スキル配布セキュリティ設定 手順書
対象: Claude Code スキルを限定配布する際のセキュリティ設定 構成: ① サイトパスワード(Cloudflare Pages)+ ② アクティベーションキー(Cloudflare Worker + KV)
Part 0: フォルダ構造の設計(作業開始前に必ず整理する)
3つのゾーンに分ける
your-project/ ← 開発フォルダ全体(自分のPC)
│
├── cloudflare/ ← ② Cloudflare にアップするもの
│ ├── worker.js ← スキル指示(秘密)+ 認証API
│ ├── wrangler.toml ← Worker設定
│ └── pages/ ← ダウンロードページ
│ ├── index.html
│ ├── _headers
│ ├── functions/
│ │ └── _middleware.js
│ └── your-skill.zip ← 配布ZIPをここに置いてデプロイ
│
├── dist/ ← ① ユーザーに配布するもの(ZIPの元)
│ └── your-skill/ ← これをZIP圧縮して cloudflare/pages/ に置く
│ ├── activate.key ← PASTE-YOUR-KEY-HERE のまま
│ ├── .env.example ← APIキー記入テンプレート
│ ├── .claude/
│ │ └── commands/
│ │ └── skill-name.md ← 認証シェルのみ(指示は含まない)
│ └── skill/
│ └── skill-name/
│ └── scripts/ ← 実行スクリプト
│
├── skill/ ← 開発用スキル本体(配布しない)
│ └── skill-name/
│ └── SKILL.md ← フル指示(機密)
│
├── output/ ← 生成物(配布しない)
│ └── articles/
│
└── docs/ ← ドキュメント(配布しない)
各ゾーンの役割
| ゾーン | アップ先 | 内容 |
|---|---|---|
cloudflare/ |
Cloudflare(Worker + Pages) | 秘密のスキル指示・認証API・ダウンロードページ |
dist/ |
ZIPにしてCloudflare Pagesに置く | ユーザーへの配布物(指示は含まない殻だけ) |
skill/ |
どこにもアップしない | スキルの本体(機密) |
output/ |
どこにもアップしない | 生成した記事・画像 |
Gitにコミットしてはいけないもの(.gitignore)
.env
activate.key
output/
skill/*/scripts/__pycache__/
skill/*/.venv/
*.key
dist フォルダ → ZIP → Pages へのフロー
① dist/your-skill/ の中身を確認・整理
↓
② ZIPに圧縮
(Windows PowerShell)
Compress-Archive -Path "dist\your-skill" -DestinationPath "dist\your-skill-v1.0.zip"
↓
③ ZIPを cloudflare/pages/ にコピー
cp dist/your-skill-v1.0.zip cloudflare/pages/
↓
④ Cloudflare Pages にデプロイ
cd cloudflare/pages
npx wrangler pages deploy . --project-name [プロジェクト名] --commit-dirty=true
dist フォルダに入れるもの / 入れないもの
| ファイル | dist に含める | 理由 |
|---|---|---|
activate.key(PASTE-YOUR-KEY-HERE) |
✅ | ユーザーがキーを入力する場所 |
.env.example |
✅ | APIキー設定の案内 |
.claude/commands/skill-name.md |
✅ | 認証シェル(指示なし) |
skill/scripts/ |
✅ | 実行に必要なスクリプト |
skill/SKILL.md(フル指示) |
❌ | 機密。Workerに保管 |
.env(実際のAPIキー) |
❌ | 絶対に含めない |
output/ |
❌ | 生成物。不要 |
.venv/ |
❌ | 容量大。ユーザーが自動生成 |
全体構成図
ユーザー
│
├─ ① ダウンロードページにアクセス
│ → Cloudflare Pages ミドルウェアがパスワードを要求
│ → パスワードOK → ZIPをダウンロード
│
└─ ② スキルを実行(/スキル名)
→ activate.key を読む
→ Cloudflare Worker に問い合わせ
→ KV でキーの有効性を確認
→ 有効 → スキル指示を返す → 実行開始
→ 無効 → エラーで停止
前提条件
- Cloudflare アカウント(無料プランでOK)
wranglerCLI がインストール済み(npm install -g wrangler)- Node.js がインストール済み
Part 1: サイトパスワード設定(Cloudflare Pages)
1-1. フォルダ構成を作る
your-project/
└── cloudflare/
└── pages/
├── index.html ← ダウンロードページ
├── _headers ← ZIPダウンロード用ヘッダー
├── your-skill.zip ← 配布ファイル
└── functions/
└── _middleware.js ← パスワードゲート
1-2. functions/_middleware.js を作成
/**
* Password Gate Middleware
* Cloudflare Pages Functions
* 環境変数 SITE_PASSWORD にパスワードを設定する。
*/
const COOKIE_NAME = 'auth_token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7日間
export async function onRequest(context) {
const { request, env, next } = context;
const PASSWORD = (env.SITE_PASSWORD || '').trim();
const url = new URL(request.url);
// POST: パスワード送信処理
if (request.method === 'POST' && url.pathname === '/__auth') {
const formData = await request.formData();
const input = formData.get('password') || '';
if (input === PASSWORD) {
const redirectTo = formData.get('redirect') || '/';
const headers = new Headers({ Location: redirectTo });
headers.append('Set-Cookie',
`${COOKIE_NAME}=${PASSWORD}; Path=/; Max-Age=${COOKIE_MAX_AGE}; HttpOnly; Secure; SameSite=Lax`
);
return new Response(null, { status: 302, headers });
}
return new Response(renderPage(true, url.pathname), {
status: 401,
headers: { 'Content-Type': 'text/html; charset=UTF-8' }
});
}
// Cookie チェック
const cookies = parseCookies(request.headers.get('Cookie') || '');
if (cookies[COOKIE_NAME] === PASSWORD) {
return next();
}
// 未認証 → パスワード画面を表示
return new Response(renderPage(false, url.pathname), {
status: 401,
headers: { 'Content-Type': 'text/html; charset=UTF-8' }
});
}
function parseCookies(header) {
const result = {};
for (const part of header.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k) result[k.trim()] = decodeURIComponent(v.join('='));
}
return result;
}
function renderPage(error, redirect) {
return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>アクセス</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Hiragino Sans', sans-serif;
background: #fafafa; min-height: 100vh;
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.card {
background: #fff; border-radius: 16px;
box-shadow: 0 2px 24px rgba(0,0,0,0.07);
padding: 48px 56px; width: 100%; max-width: 400px; text-align: center;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
p { font-size: 14px; color: #6b7280; margin-bottom: 28px; }
input[type="password"] {
width: 100%; padding: 12px 16px; font-size: 16px;
border: 1.5px solid ${error ? '#f87171' : '#e5e7eb'};
border-radius: 10px; outline: none; margin-bottom: 12px;
text-align: center; letter-spacing: 0.1em;
}
.error { font-size: 13px; color: #ef4444; margin-bottom: 12px; }
button {
width: 100%; padding: 13px; background: #1a1a1a; color: #fff;
border: none; border-radius: 10px; font-size: 15px;
font-weight: 600; cursor: pointer;
}
button:hover { background: #333; }
</style>
</head>
<body>
<div class="card">
<h1>🔒 アクセス制限</h1>
<p>アクセスするにはパスワードが必要です</p>
<form method="POST" action="/__auth">
<input type="hidden" name="redirect" value="${redirect}">
<input type="password" name="password" placeholder="パスワードを入力" autofocus autocomplete="off">
${error ? '<p class="error">パスワードが違います</p>' : ''}
<button type="submit">入力</button>
</form>
</div>
</body>
</html>`;
}
1-3. _headers を作成(ZIPを自動ダウンロードさせる)
/*.zip
Content-Disposition: attachment
1-3-b. を作成(デプロイ除外設定)
作業中に自動生成されるキャッシュやログをデプロイから除外する:
.claude
.taisun
.wrangler
.workflow_state.json
*.log
1-4. Cloudflare Pages にデプロイ
cd cloudflare/pages
npx wrangler pages deploy . --project-name [プロジェクト名]
初回は対話形式で新規プロジェクト作成を選択する
1-5. パスワードを環境変数に設定
- Cloudflare Dashboard → Workers & Pages → [プロジェクト名]
- 「設定」→「変数とシークレット」→「追加」
- 変数名:
SITE_PASSWORD(末尾スペースに注意) - 値: 任意のパスワード(例:
pass1234) - 「保存して展開」
1-6. パスワード変更方法
Dashboard の SITE_PASSWORD を編集するだけ。再デプロイ不要。
Part 2: アクティベーションキー設定(Cloudflare Worker + KV)
2-1. フォルダ構成
your-project/
└── cloudflare/
├── wrangler.toml ← Worker 設定
└── worker.js ← スキル指示を返す認証API
2-2. KV Namespace を作成
cd cloudflare
npx wrangler kv namespace create "activation-keys"
出力された id をメモしておく。
2-3. wrangler.toml を作成
name = "[worker名]"
main = "worker.js"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "KV"
id = "[2-2でメモしたKV ID]"
2-4. worker.js を作成
export default {
async fetch(request, env) {
// CORS
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
}
});
}
const url = new URL(request.url);
const key = url.searchParams.get('key');
const skill = url.searchParams.get('skill');
if (!key || !skill) {
return json({ valid: false, reason: 'missing_params' });
}
// KV からキー情報を取得
const raw = await env.KV.get(key);
if (!raw) {
return json({ valid: false, reason: 'invalid_key' });
}
const data = JSON.parse(raw);
// 有効期限チェック
if (data.expiry && new Date() > new Date(data.expiry)) {
return json({ valid: false, reason: 'expired', expiry: data.expiry });
}
// スキル使用権チェック
if (!data.skills.includes(skill)) {
return json({ valid: false, reason: 'skill_not_included' });
}
// スキル指示を返す
const instructions = getInstructions(skill);
if (!instructions) {
return json({ valid: false, reason: 'skill_not_found' });
}
return json({ valid: true, instructions });
}
};
function json(data) {
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
function getInstructions(skill) {
// スキルごとの指示を追加する
const skills = {
'skill-name': `ここにスキルの指示を記述する`,
// 'skill2': `...`
};
return skills[skill] || null;
}
2-5. Worker をデプロイ
cd cloudflare
npx wrangler deploy
デプロイ後に Worker URL が表示される(例: https://[worker名].workers.dev)
2-6. アクティベーションキーを発行
cd cloudflare
npx wrangler kv key put "NK-USER001-20260401" \
'{"expiry":"2027-01-01","skills":["skill-name"]}' \
--binding KV --remote
| フィールド | 説明 |
|---|---|
| キー名 | 任意の文字列(例: NK-USER001-20260401) |
expiry |
有効期限(ISO 8601形式) |
skills |
使用できるスキル名の配列 |
Part 3: 配布ファイル(ZIP)の構成
your-skill/ ← ZIPに圧縮するフォルダ
├── activate.key ← ユーザーがキーを入力するファイル
├── .env.example ← APIキー等の設定テンプレート
├── .claude/
│ └── commands/
│ └── skill-name.md ← スラッシュコマンド(認証シェル)
└── skill/
└── skill-name/
└── (スキルの実行ファイル群)
activate.key の初期内容
PASTE-YOUR-KEY-HERE
.claude/commands/skill-name.md(認証シェル)
# スキル名 — 認証版
## Step 1: アクティベーションキーを確認
プロジェクトルートの `activate.key` を Read ツールで読む。
内容が `PASTE-YOUR-KEY-HERE` のままなら停止してエラーを表示。
## Step 2: 認証APIからスキル指示を取得
\`\`\`bash
node -e "
const path = require('path');
const key = require('fs').readFileSync(path.join(process.cwd(), 'activate.key'), 'utf-8').trim();
const res = await fetch('https://[worker名].workers.dev/?key=' + encodeURIComponent(key) + '&skill=skill-name');
const data = await res.json();
if (!data.valid) {
const msg = data.reason === 'expired' ? '有効期限切れ(期限: ' + data.expiry + ')' : '無効なキー';
console.error('❌ エラー: ' + msg);
process.exit(1);
}
process.stdout.write(data.instructions);
" --input-type=module
\`\`\`
## Step 3: 返ってきた指示に完全に従って実行する
Part 4: 運用コマンド早見表
キー操作
# 発行
npx wrangler kv key put "NK-XXXX" '{"expiry":"2027-01-01","skills":["skill-name"]}' --binding KV --remote
# 無効化(即座に全員停止)
npx wrangler kv key delete "NK-XXXX" --binding KV --remote
# 一覧
npx wrangler kv key list --binding KV --remote
Pages 再デプロイ(パスワード変更後など)
cd cloudflare/pages
npx wrangler pages deploy . --project-name [プロジェクト名] --commit-dirty=true
Worker 再デプロイ(スキル内容を更新したとき)
cd cloudflare
npx wrangler deploy
Part 5: トラブルシューティング
| 症状 | 原因 | 対処 |
|---|---|---|
| パスワードが通らない | 環境変数名に末尾スペースがある | Dashboard で変数名を削除して再作成 |
| キーが無効と出る | --remote なしで発行した |
--remote フラグを付けて再発行 |
| スキルが見つからない | wrangler が別アカウントでログイン中 | wrangler logout → wrangler login |
| /コマンド名 が出ない | Claude Codeが親フォルダを開いている | activate.key があるフォルダを直接開く |
セキュリティ上の注意
worker.js内のスキル指示は Cloudflare に保存され、キーなしでは取得不可activate.keyはユーザーのローカルにあるが、それだけでは何もできない- パスワードと activate.key は別々に管理・変更できる
- キーを漏洩した場合は KV から削除するだけで即座に無効化可能
Part 6: デプロイ前セキュリティチェックリスト
新しいスキルにこの2段階構成を適用するたびに、デプロイ前に必ず確認する。
6-1. 機密情報の分類と管理
| 情報の種類 | 置き場所 | Git管理 | 備考 |
|---|---|---|---|
| スキルの指示・ノウハウ(コアプロンプト) | worker.js 内 または Cloudflare Secret |
❌ 除外 | キーなしで取得不可 |
| note.com ログイン情報 | .env(ローカルのみ) |
❌ 除外 | .env.example だけコミット可 |
| Gemini / 外部APIキー | .env(ローカルのみ) |
❌ 除外 | Cloudflare側はSecrets変数を使う |
サイトパスワード(SITE_PASSWORD) |
Cloudflare Dashboard の環境変数 | ❌ 記載しない | wrangler.toml に直書き禁止 |
| KV Namespace ID | wrangler.toml |
✅ OK | IDだけでは読み取り不可 |
activate.key(発行済みキー) |
ユーザーのローカルのみ | ❌ 除外 | 配布ZIPには PASTE-YOUR-KEY-HERE のプレースホルダーを入れる |
Cloudflare Worker にシークレットを渡す場合(APIキーなど):
# wrangler.toml に直書きせず、Secrets として登録する
npx wrangler secret put SECRET_NAME
# → プロンプトで値を入力(履歴に残らない)
6-2. JSファイルのログ除去チェック
デプロイ前に worker.js と _middleware.js に不要なログが残っていないか確認する。
grepで一括チェック(プロジェクトルートから実行):
grep -rn "console\.\(log\|debug\|info\|warn\)" cloudflare/
出力があった場合は削除してからデプロイする。
残してよいもの / 削除すべきもの:
| 種類 | 対応 |
|---|---|
console.error(...) |
✅ 残してOK(エラー検知に必要) |
console.log("key:", key) |
❌ 削除必須(機密情報が漏れる) |
console.debug(...) |
❌ 削除(本番不要) |
console.info(...) |
❌ 削除(本番不要) |
デバッグ用の return json({debug: ...}) |
❌ 削除必須 |
6-3. 配布ZIPに含めてはいけないファイル
ZIP圧縮前に以下が含まれていないことを確認する:
# 危険なファイルが混入していないか確認
grep -r "NK-" dist/ # 実際の発行済みキーが残っていないか
grep -r "GEMINI_API_KEY=" dist/ --include="*.env" 2>/dev/null # 実APIキーが残っていないか
| 含めてはいけないファイル | 理由 |
|---|---|
.env(実際の値入り) |
APIキー・パスワードが漏洩する |
activate.key(実際のキー入り) |
不正利用される |
output/(生成済み記事・画像) |
個人情報・著作物が混入する可能性 |
.claude/(会話履歴・フック) |
内部構造が露出する |
skill/*/SKILL.md(フル指示) |
ノウハウ・コアプロンプトが漏洩する |
node_modules/ |
容量肥大・不要 |
ZIP圧縮前の最終確認コマンド:
# dist/your-skill/ の中身を一覧表示して目視確認
ls -la dist/your-skill/
ls -la dist/your-skill/.claude/ 2>/dev/null && echo "⚠️ .claude/ が含まれています"
6-4. デプロイ前チェックリスト(コピーして使う)
【Pages デプロイ前】
□ _middleware.js に console.log が残っていない
□ SITE_PASSWORD を wrangler.toml / index.html に直書きしていない
□ .claudeignore または cloudflare/pages/.cfignore で不要ファイルを除外している
【Worker デプロイ前】
□ worker.js に console.log / console.debug が残っていない
□ APIキー・トークンを worker.js にハードコードしていない(Secrets変数を使用)
□ デバッグ用のレスポンス(debug フィールド等)を削除している
【ZIP配布前】
□ activate.key の中身が PASTE-YOUR-KEY-HERE になっている
□ .env.example のみ含まれ、.env(実値)は含まれていない
□ output/ .claude/ node_modules/ が含まれていない
□ grep -rn "console\.log" cloudflare/ で出力がゼロである