// docs / security guides / hardening
AI コーディングツールで作ったアプリの守り方
Cursor、Claude Code、Lovable、Bolt、v0、Replit、または Windsurf を使用して構築したアプリの段階的な強化ガイド。 4 つのフェーズ: AI- で生成されたアプリが異なる失敗をする理由を理解し、即時にコードベース監査を実行し、デプロイ時に強化し、監視を継続します。意見があり、物語性があり、コピーできる実際の断片が含まれています。
AI-生成されたアプリの失敗が異なる理由
Vibe コード化されたアプリは安全です。障害モードは不注意ではなく構造的なものであるため、追加の監査パスが必要です。
- Cursor inlines hardcoded keys. Cursor に「認証エラーを修正してください」と依頼すると、サービス ロール クライアントを想定した Supabase の例が貼り付けられます。キーはページ コンポーネントの先頭に配置されます。 anon クライアントとサービス クライアントの両方が共存します。どちらも船です。
- Claude Code defaults to permissive CORS. 生成された Express / Fastify ハンドラーには
cors({ origin: '*' })が同梱されています。これは、動作するプレビューを取得する最も速い方法だからです。ミドルウェアが 2 回目のパスを取得することはありません。 - Lovable and v0 skip the rules file. Firestore 支援プロジェクトはデータ モデルを生成しますが、
firestore.rulesにはほとんど触れません。テストモード ルールはサイレントに期限切れになり、ユーザーに警告することなくデータベースをロックします。 - Bolt skips RLS migrations. Bolt は、anon キーを使用する Supabase スキーマと CRUD サーフェスを生成します。
ENABLE ROW LEVEL SECURITYは移行に参加しません。匿名ユーザーは任意の行の読み取りまたは書き込みができます。 - Windsurf trusts unsigned IDs. 生成された
GET /api/items/[id]は、所有権を確認せずにパラメータを読み取り、Postgres にクエリを実行します。このパターンは十分に一般的であるため、アクティブな active.idor-walking プローブは 1 回のスキャンでそれを検出します。
即時監査: コードベースを grep してリスク パターンを調べる
何かを硬化する前に、すでに壊れているものを見つけてください。これらの grep にはそれぞれ 1 分もかかりません。
シークレットとプロバイダーキー
grep -RIn 'NEXT_PUBLIC_SUPABASE_SERVICE' src/
grep -RIn 'sk_live_\|pk_live_\|STRIPE_SECRET' src/
grep -RIn 'sk-ant-\|^sk-' src/ # Anthropic / OpenAI
grep -RIn 'AIza\|AKIA' src/ # Google / AWS
grep -RIn 'eyJh[A-Za-z0-9_-]\{20,\}' src/ # JWT-shaped stringsヒットした場合は、削除とキーのローテーションが必要です。 Provider ダッシュボード: Supabase → 設定 → API、Stripe → 開発者 → API キー、Anthropic / OpenAI コンソール。
データベースのアクセス制御
# Supabase migrations
grep -RIn 'CREATE TABLE public\.' supabase/migrations/
grep -RIn 'ENABLE ROW LEVEL SECURITY\|FORCE ROW LEVEL SECURITY' supabase/migrations/
# Firebase / Firestore
cat firestore.rules # confirm no `if true;` matchesすべての CREATE TABLE public.* には、一致する ENABLE ROW LEVEL SECURITY と少なくとも 1 つのポリシーが必要です。 Firestore ルールは読み取りの範囲を request.auth.uid に限定する必要があります。
認証とセッションの処理
grep -RIn 'getSession()' src/ # should be getUser() server-side
grep -RIn 'localStorage\.\(set\|get\)Item.*token' src/
grep -RIn 'jwt.verify.*\(noVerify\|skipVerify\)' src/サーバーでレンダリングされたルートは supabase.auth.getUser() を使用する必要があります。これはバックエンドで検証されます。 getSession() は未検証の Cookie を読み取ります。 localStorage のトークンは、ページ上で実行されるすべてのスクリプトからアクセスできます。
ヘッダーとミドルウェア
# Confirm middleware location for src/ layouts
ls src/middleware.ts middleware.ts 2>&1
# Look for CSP and security headers
grep -RIn 'Content-Security-Policy\|Strict-Transport-Security' src/src/ レイアウトでは、src/middleware.ts のみがピックアップされます。ミドルウェア ファイルがプロジェクト ルートにある場合、Next.js はそれを黙って無視し、CSP / 認証更新ロジックは実行されません。
導入時の強化
ソースがクリーンになったら、アプリが本番環境に到達する方法をロックダウンします。
ステップ 1: 別々の環境
Vercel: 3 つの環境 - Production (本番ドメイン)、プレビュー (PR / ステージング デプロイ)、開発 (ローカル)。それぞれが独自の環境変数セットを取得します。ライブ Stripe / Anthropic / Supabase キーはプレビューに到達しません。プレビュー キーは Production に到達しません。ブランチは自動的にプレビューにプッシュされます。 main にマージすると、Production にデプロイされます。
ステップ 2: ミドルウェアを介した厳密な CSP
リクエストごとに nonce を生成し、それを Content-Security-Policy に注入します。 Next.js は、x-nonce リクエスト ヘッダーを設定すると、そのノンスを独自のスクリプト タグに自動的に適用します。
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = crypto.randomUUID().replace(/-/g, '');
const csp = [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://*.supabase.co`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};ステップ 3: すべてのパブリック テーブルに RLS を強制する
RLS はデフォルトでは有効になっておらず、FORCE しない限りテーブル所有者に対して強制されません。各テーブルをロールごとの明示的なポリシーと組み合わせます。
-- supabase/migrations/XXXX_rls.sql
alter table public.profiles enable row level security;
alter table public.profiles force row level security;
create policy "profiles: read own"
on public.profiles for select
using (auth.uid() = id);
create policy "profiles: update own"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);ステップ 4: すべての API ルートでのサーバーのみの認証検証
状態を変更するすべての API ルートは、呼び出し元のサーバー側を supabase.auth.getUser() で検証します。ユーザー オブジェクトは user_id にとって信頼できる情報源になります。リクエスト本文がそれを設定することを決して信頼しないでください。
// src/app/api/items/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const body = await request.json();
const { data, error } = await supabase
.from('items')
.insert({ ...body, user_id: user.id }) // server-supplied, not from body
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json(data);
}ステップ 5: 分析をリバースプロキシする
Pro独自のドメインを介して分析を実行すると、広告ブロッカーが回避され、CSP connect-src 'self' の範囲が狭くなります。 PostHog、Plausible、Umami、カスタム イベント シンクでも同じパターンが機能します。
// src/app/api/posthog/[...path]/route.ts
import { type NextRequest } from 'next/server';
const UPSTREAM = 'https://us.i.posthog.com';
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const url = `${UPSTREAM}/${path.join('/')}`;
return fetch(url, {
method: 'POST',
headers: { 'content-type': req.headers.get('content-type') ?? 'application/json' },
body: await req.text(),
});
}ステップ 6: 認証後のバウンスでのオープンリダイレクト ガード
サインイン/サインアップ フローは通常、next クエリ パラメーターを受け入れます。同一サイトのパスではないものはすべて拒否します。/ から始め、// は使用しないでください (プロトコル相対で、ユーザーをオフサイトに送ります)。
function safeNext(raw: string | null): string {
if (!raw) return '/dashboard';
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}継続中: モニタリングと再スキャン
ドリフトはデプロイごとに発生します。セキュリティを、完了したチェックリストではなく、ループとして扱います。
実稼働ドメインを確認する
Dashboard → Domains →本番ドメインを追加 → DNS TXT または HTTP- ファイル検証 (シングルステップ)。検証が完了すると、アクティブなスキャンが利用可能になり、スケジュールされた再スキャンを有効にすることができます。
パッシブ再スキャンのスケジュールを設定する
Hobby は毎日、Pro は 3 時間ごと、Unlimited は 1 時間ごとに実行されます。実行するたびに、新しい結果が表示されると電子メールが送信され、購読している場合は scan.completed Webhook が起動されます。
# Or from CI, via the REST API:
curl -X POST https://fixvibe.app/api/v1/scans \
-H "authorization: Bearer $FIXVIBE_TOKEN" \
-H "content-type: application/json" \
-d '{"target":"https://your-app.com"}'API-アクティブ スキャンを有効にする (オプション)
自動アクティブ プローブ (SQLi / XSS / IDOR ウォーキング / など) が必要な場合は、[ダッシュボード] → [ドメイン] → [API アクティブ] でドメインごとに有効にします。承認は永続的で、90 日間の有効期限があり、すぐに取り消すことができます。 scan.active_api.first_used Webhook と組み合わせて、有効化後の最初の自動アクティブ スキャンがアラートに届くようにします。
調査結果を AI ワークフローに組み込む
Mint an API token at Account → API tokens, then configure the MCP server (/docs/mcp) in Claude Desktop / Cursor / Continue. Ask your agent: "Run a scan on staging and show me the highest-severity findings." The agent calls FixVibe, fetches the report, and renders categorized remediation guidance so code/config fixes become prompts and DNS/provider/manual fixes become operator steps.
ライブ脅威検出 (Unlimited)
証明書の透明性ログの差分により、ドメインに対して発行された新しい TLS 証明書が明らかになります。 DNS-レコードの差分は、不正な変更を検出します。 JS-バンドル シークレットの監視は、新しいキーが出荷されたバンドルに到着した瞬間に起動されます。脅威インテリジェンス フィード (Spamhaus、URLhaus) は、ドメインがリストされている場合に報告します。
実際の失敗パターンとその修正
AI-生成された数千のアプリを対象とした運用スキャンから得られた 5 つのパターン。それぞれに実際の修正が含まれています。
- クライアントコンポーネントのサービスロールキー
Symptom:
baas.supabase-service-keyプロダクションに関する発見 URL。 Cause: Cursor オートコンプリートがcreateClient(URL, SERVICE_ROLE_KEY)を React コンポーネントに貼り付けました。 Fix: サービス クライアントをimport 'server-only'を先頭にしてsrc/lib/supabase/service.tsに移動します。クライアント側で使用する anon キーを使用して並列src/lib/supabase/client.tsを作成します。 Supabase Studio を介してサービス ロール キーをローテーションします。 - Firestore ルールはテストモードのまま
Symptom:
baas.firebase-rules重大度の高い所見。 Cause: で生成されたルールはallow read, write: if request.time < timestamp.date(2026, 6, 1);を読み取り、時間制限のある「すべてを許可」します。 Fix: 各ルールのスコープを認証されたユーザー (match /users/{userId}/posts/{postId} { allow read, write: if request.auth.uid == userId; }) に設定し、firebase deploy --only firestore:rulesを再展開します。 - 寛容な CORS が本番環境でも存続
Symptom:
active.cors重大度が高くなります。 Cause: が生成した Express ミドルウェア:app.use(cors({ origin: '*' }))。 Fix: フロントエンドのオリジンをホワイトリストに登録します:app.use(cors({ origin: ['https://your-app.com'], credentials: true }))。 Next.js API ルートの場合は、応答でAccess-Control-Allow-Originを明示的に設定します。 - RLS は有効ですが強制されていません
Symptom: active
baas.supabase-rlsは、ダッシュボードで RLS が有効になっている場合でも、anon ロールがパブリック テーブルに書き込むことができることを報告します。 Cause:ENABLEFORCEを省略すると、テーブル所有者は除外され、移行は所有者として実行されます。 Fix: 移行にalter table public.items force row level security;を追加します。再展開します。 - 署名されていない IDOR-ウォーク可能な ID
Symptom:
active.idor-walkingは、anon ユーザーがテナント全体で/api/items/1、/api/items/2、... を読み取ることができると報告しています。 Cause: API ハンドラーはパス パラメータを信頼し、所有権述語なしで Postgres にクエリを実行します。 Fix: は、すべての読み取りクエリに.eq('user_id', user.id)を追加するか、/api/users/[uid]/items/[id]にスコープされる署名付き URL/UUID に移動します。
バイブコードセキュリティループ
目標は完璧なセキュリティではありません。これにより、AI ツールが常に見逃してしまう簡単な成果がなくなり、迅速な出荷を続けることができます。
- Generate fast — Cursor、Claude Code、Lovable、Bolt を使用します。それがポイントです。
- Audit immediately — 上記の grep セットを実行し、RLS を確認し、CSP を確認し、認証境界を確認します。
- Harden at deploy — ミドルウェア、環境分離、CSP nonce、HSTS、サーバーのみの認証検証。
- Monitor — FixVibe は毎日パッシブ、検証済みドメインで毎週アクティブ、Slack への Webhook、Unlimited で脅威検出。
- Fix fast — use FixVibe coding-agent prompts for code/config findings and operator steps for DNS, provider, secret-rotation, or manual-review findings. Re-deploy, re-scan, close the loop.
次のステップ
DAST と SAST の概念的な背景と、AI- で生成されたアプリに独自のスキャンが必要な理由については、AI-generated code security scanning を参照してください。出荷前監査のクイックリファレンスについては、vibe coding security checklist を参照してください。
