security について
Webアプリ開発者向けのセキュリティスキルで、認証、シークレット管理、入力検証、一般的な脆弱性への対策を案内します。Supabase、Next.js、Pythonバックエンドの実践パターンに加え、トピック別資料とデプロイ前チェックリストを備えています。
主な機能
- 01JWTとセッションのセキュリティパターン適用
- 02シークレットの管理と検証
- 03ユーザー入力の検証とサニタイズ
- 04SQLインジェクション、XSS、CSRFの防止
- 05Supabase RLSとセキュリティヘッダーの設定
- 06デプロイ前のセキュリティチェック
活用シーン
- 01Webアプリの認証方式を設計
- 02シークレットと環境変数を保護
- 03アプリケーション境界でデータを検証
- 04デプロイ前にセキュリティ対策を確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ scientiacapital/ skills --skill securityインストール後、Claude Code で/securityを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
config.jsonreference/auth-patterns.mdinput-validation.mdowasp-top-10.mdrls-policies.mdsecrets-management.md詳細
セキュリティは任意ではありません。基本的な要件です。このスキルは、後からセキュリティを付け足すのではなく、最初から安全なアプリケーションを構築するのに役立ちます。
<quick_start> あらゆる新規プロジェクトにおけるセキュリティの要点:
-
シークレット: 決して git にコミットせず、起動時に検証する
// .env (gitignored) + envSchema.parse(process.env) -
認証: 短命な JWT + httpOnly Cookie
jwt.sign(payload, secret, { expiresIn: '15m' }) -
入力: すべてをスキーマで検証する
const data = z.object({ email: z.string().email() }).parse(input) -
SQL: 常にパラメーター化クエリを使用する(ORM がこれを処理します)
-
RLS: すべての Supabase テーブルで有効化し、ユーザースコープのポリシーを設定する </quick_start>
<success_criteria> セキュリティの実装は、次の場合に成功です:
- すべてのシークレットが環境変数にあり、起動時に検証されている
- バージョン管理にシークレットがない(gitleaks で確認済み)
- JWT トークンが短命(15 分以下)で、リフレッシュトークンのローテーションがある
- すべてのユーザー入力が Zod または同様のスキーマ検証で検証されている
- すべてのデータベーステーブルで RLS が有効化され、適切なポリシーが設定されている
- CSP ヘッダーが設定されている(可能な限り unsafe-inline を使用しない)
- デプロイ前にセキュリティチェックリストが完了している </success_criteria>
<security_mindset>
セキュリティマインドセット
基本原則
- 多層防御 - 1 つの壁ではなく、複数のセキュリティ層
- 最小権限 - 必要な最小限のアクセス権のみを付与する
- 入力を決して信頼しない - ユーザーおよび外部システムからのすべてを検証する
- 安全側にフェイルする - エラーはアクセスを許可するのではなく、拒否するべき
- シークレットは秘密に保つ - API キーは決してコードやログに含めない
問いかけるべきセキュリティ上の質問
機能をリリースする前に:
- これはどのようなデータを公開するか?
- このエンドポイント/ページにアクセスできるのは誰か?
- ユーザーが悪意のある入力を送信したらどうなるか?
- シークレットは適切に保護されているか?
- 機密データがログに記録されているか? </security_mindset>
JWT とセッション
| 観点 | JWT | セッション |
|---|---|---|
| 保存場所 | クライアント(localStorage/Cookie) | サーバー(DB/Redis) |
| スケーラビリティ | ステートレスで、スケールしやすい | 共有セッションストアが必要 |
| 失効 | 困難(ブラックリストが必要) | 容易(ストアから削除) |
| サイズ | 大きい(クレームを含む) | 小さい(セッション ID のみ) |
| 最適な用途 | API、マイクロサービス | 従来型の Web アプリ |
JWT のベストプラクティス
// DO: Short-lived access tokens + refresh tokens
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived!
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// DON'T: Long-lived tokens with sensitive data
// Bad example - never do this:
// { expiresIn: '365d' } // Too long!
// Including PII like SSN in token payload
トークンの保存
| 方法 | XSS 安全 | CSRF 安全 | 推奨 |
|---|---|---|---|
| localStorage | いいえ | はい | 認証には避ける |
| httpOnly Cookie | はい | いいえ(CSRF トークンが必要) | 推奨 |
| メモリ(変数) | はい | はい | SPA に最適 |
リフレッシュトークンフロー
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ │ Server │ │ DB │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ Login (email, password) │ │
│─────────────────────────────>│ │
│ │ Verify credentials │
│ │─────────────────────────────>│
│ │<─────────────────────────────│
│ Access token (15m) │ │
│ Refresh token (7d) │ Store refresh token hash │
│<─────────────────────────────│─────────────────────────────>│
│ │ │
│ API call + access token │ │
│─────────────────────────────>│ │
│ Response │ │
│<─────────────────────────────│ │
│ │ │
│ [Access token expired] │ │
│ Refresh token │ │
│─────────────────────────────>│ Verify refresh token │
│ │─────────────────────────────>│
│ New access token │ │
│<─────────────────────────────│ │
パスワードの扱い
import bcrypt from 'bcrypt';
// DO: Hash with sufficient rounds
const SALT_ROUNDS = 12; // ~300ms on modern hardware
const hash = await bcrypt.hash(password, SALT_ROUNDS);
// DO: Constant-time comparison
const isValid = await bcrypt.compare(inputPassword, storedHash);
// DON'T: Use weak hashing algorithms like MD5 or SHA1 for passwords
パスワード要件
const passwordSchema = z.string()
.min(8, 'Minimum 8 characters')
.max(128, 'Maximum 128 characters')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^a-zA-Z0-9]/, 'Must contain special character');
// Check against common passwords (haveibeenpwned API)
async function isPasswordPwned(password: string): Promise<boolean> {
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
return text.includes(suffix);
}
<secrets_management>
シークレット管理
黄金律: シークレットを決してバージョン管理にコミットしないでください。.env ファイル(gitignore 対象)を使用し、Zod スキーマですべての環境変数を起動時に検証し、複数の有効なシークレットによるローテーションをサポートし、機密データを決してログに記録しないでください。
gitignore パターン、環境変数テンプレート、Zod 検証、ローテーションパターン、ログのマスキングについては reference/secrets-management.md を参照してください。
</secrets_management>
<input_validation>
入力検証
システム境界での検証
┌─────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ ┌──────────┐ VALIDATE ┌──────────────────┐ │
│ │ User │ ───────────────> │ Business Logic │ │
│ │ Input │ │ (trusted data) │ │
│ └──────────┘ └──────────────────┘ │
│ │
│ ┌──────────┐ VALIDATE ┌──────────────────┐ │
│ │ External │ ───────────────> │ Services │ │
│ │ API │ │ │ │
│ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
スキーマ検証(Zod)
import { z } from 'zod';
// Define schemas
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
name: z.string().min(1).max(100),
age: z.number().int().min(13).max(120).optional(),
});
// Validate input
export async function createUser(input: unknown) {
const data = createUserSchema.parse(input); // Throws if invalid
// data is now typed and validated
return db.users.create(data);
}
// API handler
export async function POST(req: Request) {
try {
const body = await req.json();
const user = await createUser(body);
return Response.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json({ errors: error.errors }, { status: 400 });
}
throw error;
}
}
サニタイズ
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
// Sanitize HTML (for rich text fields)
const cleanHtml = purify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href']
});
// For plain text - escape or strip HTML
const plainText = purify.sanitize(userInput, { ALLOWED_TAGS: [] });
</input_validation>
<sql_injection>
SQL インジェクションの防止
問題点
攻撃者は、サニタイズされていない入力を通じて SQL クエリを操作できます。
攻撃ペイロードの例: '; DROP TABLE users; --
解決策: パラメーター化クエリ
// SAFE: Parameterized query (Prisma)
const user = await prisma.user.findUnique({
where: { email: email }
});
// SAFE: Parameterized query (raw SQL)
const user = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
// SAFE: Supabase
const { data } = await supabase
.from('users')
.select()
.eq('email', email);
Supabase RLS(行レベルセキュリティ)
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only see their own posts
CREATE POLICY "Users see own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
-- Users can only create posts as themselves
CREATE POLICY "Users create own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own posts
CREATE POLICY "Users update own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id);
-- Users can only delete their own posts
CREATE POLICY "Users delete own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
</sql_injection>
<xss_prevention>
XSS の防止
問題点
攻撃者は、被害者のブラウザーで実行される悪意のあるスクリプトを注入し、Cookie やデータを窃取します。
解決策: 自動エスケープ + CSP
// React auto-escapes by default - this is safe
return <div>Welcome, {userName}</div>;
// AVOID rendering raw HTML from user input
// If you absolutely must render user HTML, ALWAYS sanitize with DOMPurify first
import DOMPurify from 'dompurify';
const sanitizedContent = DOMPurify.sanitize(userContent);
コンテンツセキュリティポリシー
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'", // Avoid 'unsafe-inline' if possible
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.supabase.co",
].join('; ')
}
];
</xss_prevention>
<csrf_protection>
CSRF 保護
問題
攻撃者は、認証済みユーザーをだまして、サイトに悪意のあるリクエストを送信させます。
解決策:CSRF トークン + SameSite Cookie
// Server: Generate token
import { randomBytes } from 'crypto';
function generateCsrfToken(): string {
return randomBytes(32).toString('hex');
}
// Store in session
session.csrfToken = generateCsrfToken();
// Client: Include in forms as hidden field
// Server: Validate token matches session
// Modern approach: SameSite cookies (most effective)
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict', // or 'lax'
});
</csrf_protection>
詳細なパターンについては、該当するリファレンスを読み込んでください。| トピック | リファレンスファイル | 読み込むタイミング |
|---|---|---|
| 認証パターン | reference/auth-patterns.md | JWT、OAuth、セッション |
| シークレット | reference/secrets-management.md | API キー、環境変数 |
| 入力検証 | reference/input-validation.md | サニタイズ、スキーマ |
| Supabase RLS | reference/rls-policies.md | 行レベルセキュリティ |
| OWASP Top 10 | reference/owasp-top-10.md | 脆弱性チェックリスト |
読み込み方法: 特定のトピックを指定するか、コンテキストから必要性を判断してください。
## セキュリティチェックリストデプロイ前に確認してください。
認証
- パスワードは bcrypt でハッシュ化(12 ラウンド以上)
- JWT トークンの有効期間は短く設定(最大 15 分)
- リフレッシュトークンを安全に保管
- セッション Cookie に httpOnly + secure + sameSite を設定
シークレット
- コードやバージョン管理にシークレットが含まれていない
- 起動時に環境変数を検証
- シークレットをログに記録しない
入力
- すべてのユーザー入力をスキーマで検証
- SQL ではパラメーター化クエリを使用
- 描画前に HTML をサニタイズ
- ファイルアップロードを検証(種類、サイズ、名前)
ヘッダー
- HTTPS を強制
- CSP ヘッダーを設定
- CORS を許可されたオリジンに制限
- セキュリティヘッダーを設定(HSTS、X-Frame-Options)
認可
- すべてのテーブルで RLS を有効化
- API エンドポイントで権限を確認
- 管理者用ルートを保護
- 認証エンドポイントにレート制限を設定
結果サイドカーの出力
最後の手順として、~/.claude/skill-analytics/last-outcome-security.json に書き込みます。
{"ts":"[UTC ISO8601]","skill":"security","version":"1.0.0","variant":"default",
"status":"[success|partial|error]","runtime_ms":[estimated ms from start],
"metrics":{"vulnerabilities_found":[n],"fixes_applied":[n],"audit_checks_passed":[n]},
"error":null,"session_id":"[YYYY-MM-DD]"}
一部の段階で失敗したものの結果が生成された場合は、ステータスに "partial" を使用します。出力が生成されなかった場合に限り、"error" を使用します。