はじめに
前回、完成したと思ったら色々問題があった話をしました。(今後も問題があるかもしれませんが)
もうデプロイしちゃいました(栽培アプリはこちらから)が、デプロイ前のセキュリティチェックも実施してますので、書き記しておこうと思います。
なぜセキュリティチェックが必要か
実際に起きるリスク
1. ユーザーデータの漏洩
- 他のユーザーの圃場データが見えてしまう
- 作業記録が筒抜け
- 個人情報(メールアドレスなど)が漏洩
2. 不正なデータ操作
- 他人の作業記録を削除される
- データを改ざんされる
- システムを破壊される(DROP TABLEなど)
3. アカウント乗っ取り
- 認証トークンが盗まれる
- 他人になりすまされる
個人開発でも、ユーザーの信頼を守る責任があります。
基本的なセキュリティ対策は、最低限のマナーだと思って取り組みました。
セキュリティチェックリスト(6項目)
実際にチェックした項目は以下の6つです。
- ✅ RLSポリシーの確認
- ✅ XSS(クロスサイトスクリプティング)対策
- ✅ SQLインジェクション対策
- ✅ トークン管理(Cookie設定)
- ✅ CORS設定の確認
- ✅セキュリティヘッダーの設定
それぞれ詳しく見ていきます。
1. RLSポリシーの確認
何をチェック?
Row Level Security(RLS)は、Supabaseの最重要セキュリティ機能です。
- 全テーブルでRLSが有効になっているか
- ポリシーの条件が正しいか
{public}vs{authenticated}の使い分け
確認方法
Supabase Dashboard → SQL Editorで以下を実行:
SELECT
tablename,
policyname,
cmd,
roles
FROM pg_policies
WHERE schemaname = 'public'
AND tablename LIKE 'cult_%'
AND cmd IN ('INSERT', 'UPDATE', 'DELETE')
ORDER BY tablename, cmd;
発見した問題
全てのポリシーが {public} になっていました ❌

問題点
{public} はPostgreSQLの「全員に適用」ロールです。ログイン済みかどうかに関わらず適用対象になるため、ポリシーの条件次第では意図しない公開につながりやすい設定です。
正しい設定
{authenticated} = ログイン済みユーザーのみ
-- 全テーブルのINSERT/UPDATE/DELETEポリシーを修正
ALTER POLICY "Users can insert own beds" ON cult_beds TO authenticated;
ALTER POLICY "Users can update own beds" ON cult_beds TO authenticated;
ALTER POLICY "Users can delete own beds" ON cult_beds TO authenticated;
-- 以下、全テーブルで同様に修正...
なぜこの違いが重要か
実際のリスク比較:
| 設定 | ログイン前 | ログイン後 | リスク |
|---|---|---|---|
{public} | 適用対象になる(条件次第) | 操作可能 | ❌ 高 |
{authenticated} | 適用外 | 操作可能 | ✅ 低 |
たとえ条件式(USING句)で auth.uid() をチェックしていても:
- 将来のポリシー追加時に
USING (true)を書いてしまう - 意図しない穴が開く可能性
{authenticated} なら、この種のミスを防げます。
2. XSS(クロスサイトスクリプティング)対策
何をチェック?
ユーザーが入力した内容が、そのままJavaScriptとして実行されないかを確認します。
テスト方法
作業記録のメモ欄に、悪意のあるコードを入力してみます:
<script>alert('XSS test')</script>

結果

<script>alert('XSS test')</script> というテキストが表示されました。
JavaScriptとして実行されていません ✅
なぜ安全だったのか
React の自動エスケープ機能
Reactは、デフォルトで全ての変数を安全な文字列に変換します:
// これは安全
<p>{log.memo}</p>
// 危険(使ってはいけない)
<p dangerouslySetInnerHTML={{ __html: log.memo }} />
dangerouslySetInnerHTML を使わない限り、XSSは発生しません。
なぜ安全だったのか
React の自動エスケープ機能
Reactは、デフォルトで全ての変数を安全な文字列に変換します:
// これは安全
<p>{log.memo}</p>
// 危険(使ってはいけない)
<p dangerouslySetInnerHTML={{ __html: log.memo }} />
通常のReactのJSX埋め込みでは自動エスケープが働くため、今回試した入力経路では実行されませんでした。
ただし、dangerouslySetInnerHTML や innerHTML、危険な外部ライブラリの利用など、別の経路からXSSが発生する可能性は残ります。そのため、ReactのエスケープだけではすべてのXSSを防げるわけではなく、後述するCSP(Content-Security-Policy)によるブラウザレベルの防御も組み合わせています。
3. SQLインジェクション対策
何をチェック?
ユーザーが入力した内容が、そのままSQLとして実行されないかを確認します。
テスト方法
作業記録のメモ欄に、SQL文を入力してみます:
'; DROP TABLE users--
これはSQLインジェクションのテストでよく使われる文字列です。
結果

'; DROP TABLE users-- というただの文字列として保存されました。
SQLとして実行されていません ✅
なぜ安全だったのか
Supabase のクエリビルダーを使用しています。
// 安全
const { data } = await supabase
.from('cult_logs')
.insert({ memo: userInput });
// 危険(使ってはいけない)
await supabase.rpc('execute_raw_sql', { sql: userInput });
この方法では、ユーザー入力は SQLの命令ではなくデータとして扱われます。
Supabaseのクエリビルダーを使用している限り、ユーザー入力はSQL文ではなくパラメータとして処理されるため、 SQLインジェクションのリスクは低くなります。
もし対策されていない場合
もしユーザー入力をそのままSQLに組み込んでしまうと、次のようなことが起こる可能性があります。
-- 本来のSQL
INSERT INTO cult_logs (memo) VALUES ('ユーザー入力');
-- 攻撃された場合
INSERT INTO cult_logs (memo) VALUES (''); DROP TABLE users--');
↑ここで文字列終了
↑テーブル削除コマンド実行
↑以降をコメントアウト
この場合:
usersテーブルが削除される- 全ユーザーデータが消失
- アプリが完全に動かなくなる
と、いった深刻な問題につながります。
チェックポイント:
SQLインジェクションを防ぐために、次の点を確認しました。
- ユーザー入力を 直接SQL文に埋め込んでいない
- Supabaseの クエリビルダーを使用している
今回のアプリでは、この条件を満たしているため SQLインジェクションのリスクは低い構成になっています。
4. トークン管理(Cookie設定)
何をチェック?
認証トークンが安全に保存されているかを確認します。
理想的な状態:
- ✅ HttpOnly Cookie に保存
- ✅ JavaScriptからアクセス不可
確認方法
ブラウザの開発者ツールで確認します:
F12を押して開発者ツールを開くApplicationタブ →Cookies- HttpOnly 列を確認

発見した問題
HttpOnly 列が空欄でした。
JavaScriptから document.cookie でアクセスできる状態です。
これは問題?
結論:この構成では、やむを得ない面があります
実は、この状態には理由があります。
構成上の理由
今回のアプリはブラウザからSupabaseへ直接アクセスする構成を採用しています。そのため、ブラウザ側でセッション管理が必要になり、一般的な「HttpOnly Cookieのみで完結する認証構成」とは少し異なる仕組みになっています。
Supabase公式の立場
Supabaseの公式ドキュメントでは、HttpOnly Cookieについて次のように説明されています:
This is not necessary. Both the access token and refresh token are designed to be passed around to different components in your application.(HttpOnly Cookie にする必要はありません。アクセストークンとリフレッシュトークンは、アプリ内の様々なコンポーネントで扱われることを前提に設計されています。)
つまり、Supabaseのブラウザ構成においてHttpOnlyを必須にしない設計は、公式に認められたアプローチです。
ただし「盗まれても平気」ではない
これは「トークンが盗まれても安全」という意味ではありません。JavaScriptからトークンにアクセスできる以上、XSSによってトークンが盗まれるリスクは残ります。盗まれたトークンを使えば、本人の権限でデータを操作される可能性があります。RLSは「他人のデータへのアクセスを防ぐ」仕組みですが、本人権限での不正操作まではカバーできません。
だからこそ、2番で説明したXSS対策と、後述するCSPによる多層防御が重要になります。
より安全にする方法(将来の課題)
全ての認証処理を Server Action 化することで、HttpOnly Cookieを使用できます:
// server/actions.ts
'use server'
import { createServerClient } from '@supabase/ssr'
export async function login(email: string, password: string) {
const supabase = createServerClient(...)
return await supabase.auth.signInWithPassword({ email, password })
}
今回は実装コストの観点から見送りましたが、将来的な課題として認識しています。
5. CORS設定の確認
今回の構成ではNext.js側にCORS設定は不要
今回のアプリの構成は次のようになっています:
ブラウザ
↓
Supabase API
↓
DB
この構成では、CORS設定はSupabase側が管理しています。Next.jsはAPIサーバーとして動作しているわけではないため、CORS設定を書く場所がそもそもありません。
では、どんなときにCORS設定が必要になるのか?
Next.jsをAPIサーバーとして使う場合です。たとえば:
1. 秘密情報をブラウザに渡したくないとき 外部APIキーや決済用の秘密鍵などはブラウザに置けません。そのため:
ブラウザ → Next.js → 外部API
Stripeの決済やOpenAI API呼び出しなどがこれにあたります。
2. 入力チェックや権限制御をサーバー側でやりたいとき ログイン確認・管理者チェック・バリデーションなどをNext.js側で行う場合です。
3. 複数の外部サービスをまとめたいとき
ブラウザ → Next.js → Supabase / Google Sheets / メール送信
登録ボタン1回でDB保存・通知メール送信をまとめてやる場合などです。
今回はこれらに該当しないため、設定は不要でした。構成が変わる場合は改めて確認が必要です。
認証リダイレクト設定の確認
あわせて、Supabase Dashboard → Authentication → URL Configurationの設定も確認しました。これはCORSとは別の話で、認証フローの正しい遷移先を設定するものです。
- Site URL:パスワードリセットメールのリンク先など
- Redirect URLs:認証後にリダイレクトを許可するURL一覧
今回はメール/パスワード認証のみのため、設定しなくても動作します。ただし将来的にOAuthやMagic Linkを追加する場合は設定が必要になるため、今のうちに確認・設定しておきました。
なお、構成が変わる場合(Next.jsをAPIサーバーとして使う場合など)はCORS設定が必要になるため、改めて確認が必要です。
6. セキュリティヘッダーの設定
XSS対策の「一次防御」と「追加防御」
XSS対策としては、前の章で説明したReactの自動エスケープが基本になります。ユーザー入力をそのままHTMLとして表示しないことで、多くのXSSは防ぐことができます。
ただし、ソフトウェアにはバグが入り込む可能性があります。もし将来どこかで不注意な実装が入り、不正なJavaScriptがページに混入してしまった場合を考えると、もう一段の防御が欲しくなります。
そこで設定したのが Content Security Policy(CSP) です。
CSPはXSSそのものを防ぐ仕組みではなく、万が一不正なスクリプトが実行された場合でも「外部サイトへの通信」や「外部スクリプトの読み込み」を制限することで、被害を広げにくくする仕組みです。
今回の設定と限界
今回の設定では:
script-src 'self' 'unsafe-inline'
この設定ではインラインスクリプト(HTML内の <script>)は許可されています。そのため、CSPだけでXSSを完全に防ぐことはできません。
ただし、
- 外部サイトからのスクリプト読み込み
- 外部ドメインへの通信
などは制限されるため、万が一不正コードが混入しても被害の拡大を抑える効果があります。
多層防御のまとめ
このアプリでは次のような多層防御になっています:
- Reactの自動エスケープ → ユーザー入力によるXSSを防ぐ(一次防御)
- 危険なAPIを使わない →
dangerouslySetInnerHTMLなどを使用しない - CSP(ブラウザ側の制御) → 万が一の不正コード実行時でも被害を広げにくくする
複数の防御を組み合わせることで、リスクを下げています。
セキュリティヘッダーの実装
Next.jsでは next.config.ts に設定を書くことで、全ページにセキュリティヘッダーを追加できます。
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
async headers() {
return [
{
source: "/:path*",
headers: [
// クリックジャッキング対策
{ key: "X-Frame-Options", value: "DENY" },
// MIMEスニッフィング対策
{ key: "X-Content-Type-Options", value: "nosniff" },
// 参照元(リファラ)の漏れを抑える
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// 権限系(カメラ・位置情報など)を基本OFF
{
key: "Permissions-Policy",
value: [
"accelerometer=()",
"camera=()",
"geolocation=()",
"gyroscope=()",
"magnetometer=()",
"microphone=()",
"payment=()",
"usb=()",
"interest-cohort=()",
].join(", "),
},
// CSP(追加防御入り)
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"worker-src 'self' blob:",
].join("; "),
},
],
},
];
},
};
export default nextConfig;
各ヘッダーの意味
① X-Frame-Options: DENY
意味:「このサイトをiframeで埋め込むな」
防げる攻撃:クリックジャッキング
具体例:悪意のあるサイトがあなたのログインボタンを透明なiframeで覆い、ユーザーに気づかせずクリックさせる攻撃を防ぎます。
② X-Content-Type-Options: nosniff
意味:「ブラウザさん、勝手にファイルの種類を推測しないで」
防げる攻撃:MIMEスニッフィング攻撃
具体例:画像ファイルのふりをしたJavaScriptが実行されるのを防ぎます。
③ Referrer-Policy
意味:「どこから来たかの情報は、必要最小限だけ教えて」
防げる問題:URLに含まれる個人情報の漏洩
④ Permissions-Policy
意味:「カメラ・マイク・位置情報などのブラウザ機能をOFFにする」
防げる問題:不要なデバイス機能への不正アクセス
⑤ Content-Security-Policy(CSP)
| 設定 | 意味 |
|---|---|
default-src 'self' | 基本は自分のサイトからだけ読み込む |
base-uri 'self' | <base>タグの悪用を防ぐ |
object-src 'none' | FlashなどのプラグインをすべてNG |
frame-ancestors 'none' | 他サイトからのiframe埋め込みを禁止 |
form-action 'self' | フォームの送信先を自分のサイトに限定 |
外部サービスを使う場合の追加設定
Supabase を使う場合:
"connect-src 'self' https://*.supabase.co",
Google Analytics(GA4) を使う場合:
"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
"img-src 'self' data: https: https://www.google-analytics.com",
"connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com",
もしブラウザのコンソールに
Refused to load because of Content Security Policy
というエラーが出た場合は、CSPによってブロックされています。エラーに表示されているドメインを該当の src に追加してください。
まとめ
| 対策 | 内容 |
|---|---|
| ① RLSポリシー | authenticated に統一し、意図しない公開を防ぐ |
| ② XSS対策 | Reactの自動エスケープ(一次防御)+CSPで多層化 |
| ③ SQLインジェクション対策 | 確認した経路ではクエリビルダーで安全。生SQL追加時は要注意 |
| ④ トークン管理 | 構成上HttpOnly不可。XSS対策とCSPで補完 |
| ⑤ CORS設定 | この構成ではNext.js側に設定箇所なし。Supabase側が管理 |
| ⑥ セキュリティヘッダー | CSPで外部リソース制限。被害拡大防止の追加防御 |
フレームワークに感謝
今回のセキュリティ対策の大部分は、Next.js と Supabase のおかげです。
自動的に守られていること
- XSS対策(Reactの自動エスケープ)
- SQLインジェクション対策(Supabaseのクエリビルダー)
- HTTPS化(Vercelの自動証明書)
- 認証トークン管理(Supabase Auth)
※ただし、HTMLを直接挿入する場合や生SQLを使う場合は別途対策が必要です。
自分で設計したこと(重要)
- RLSポリシーの設計(最重要)
- Content-Security-Policy の追加
- X-Frame-Options などのヘッダー追加
モダンなフレームワークは「基礎体力」を守ってくれます。その上で、アプリ固有のセキュリティ(RLSなど)を自分で設計する。これが個人開発でも安全なアプリを作るコツだと学びました。
デプロイ手順(簡潔に)
1. Supabaseプロジェクト作成
- https://supabase.com/dashboard にアクセス
- “New project” をクリック
- 以下を設定:
- Name:
crop-tracker-demo - Region:
Northeast Asia (Tokyo) - Pricing Plan:
Free
- Name:
2. データベース移行
テーブル構造のコピー
ローカル Supabase → Table Editor
各テーブルを右クリック → Copy table schema → デモ版で実行
RLSポリシーの適用
ローカルで設定した全ポリシーをデモ版にも適用
関数の作成
-- my_org_id() 関数
CREATE OR REPLACE FUNCTION public.my_org_id()
RETURNS uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT organization_id
FROM public.cult_workers
WHERE user_id = auth.uid()
LIMIT 1
$function$;
GRANT EXECUTE ON FUNCTION public.my_org_id() TO authenticated;
-- my_worker_id() 関数も同様に作成
3. Vercel デプロイ
- https://vercel.com にアクセス
- Add New… → Project
crop-cultivation-appをインポート- 以下を設定:
Build Command: pnpm run build
Install Command: pnpm install
Environment Variables:
NEXT_PUBLIC_SUPABASE_URL=デモ版のProject URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=デモ版のanon key
SUPABASE_SERVICE_ROLE_KEY=デモ版のservice_role key
NEXT_PUBLIC_DEMO_MODE=true
- Deploy をクリック
※デプロイ完了しております。→ 栽培管理アプリのデモはこちら
