セキュアコーディングがうまくいかないチームには、共通点があります。「気をつける」「レビューで気づく」という、人間の注意深さに依存していることです。注意深さは疲れます。締め切り前に薄れます。新人には備わっていません。だから、安全性を“個人の努力”ではなく“仕組み”に変える——それが本記事のテーマです。
幸い、車輪を再発明する必要はありません。NISTとOWASPが、安全な作り方を公式に体系化してくれています。本記事は、この2つの一次情報を地図に、セキュアコーディングの原則をコピーして使える実コードで示し、最後にそれをCIで自動強制するところまで通します。
この記事と姉妹記事の関係: 「そもそもどんな職種を目指すか」はセキュリティエンジニアになるには【完全ロードマップ】に。本記事はその中核技能「安全に作る技術」を深掘りします。コード例はTypeScript/Next.js中心ですが、原則は言語非依存です。特定スタック(Next.js × Supabase)での具体的な脆弱性検出・修正は、各所で個別記事へリンクします。
0. 2つの公式フレームワーク — SSDF と ASVS
まず、これから何度も参照する2つの地図を押さえます。
NIST SSDF(Secure Software Development Framework / SP 800-218)
SSDF(SP 800-218 v1.1)は、安全なソフトウェアの“作り方”を4つの実践群に整理した、開発プロセスのフレームワークです(v1.2が2025年12月にドラフト公開・意見募集中)。
| 実践群 | 何をするか |
|---|---|
| PO(Prepare the Organization) | 組織として安全に作る準備(方針・役割・ツールの整備) |
| PS(Protect the Software) | ソフトウェア自体とコードを保護(改ざん防止・完全性・秘密情報の保護) |
| PW(Produce Well-Secured Software) | よく守られたソフトウェアを生み出す(設計・セキュアコーディング・レビュー・テスト) |
| RV(Respond to Vulnerabilities) | 脆弱性に対応する(発見・修正・再発防止) |
本記事の§1〜§5は主に PW(安全に書く)、§6は PS/PW の自動化、§7は RV の入口にあたります。
OWASP ASVS 5.0(Application Security Verification Standard)
ASVS 5.0.0(2025年5月公開、専用サイト asvs.dev)は、「安全なアプリが満たすべき要件」のチェックリストです。約350の検証要件を17章に整理し、3段階のレベルで「どこまで守るか」を選べます。
- レベル1: 最低限(多くのアプリの出発点)
- レベル2: 機密データを扱う大半のアプリの推奨水準
- レベル3: 医療・金融など、最高水準が必要なアプリ
SSDFが「どう作るか(プロセス)」、ASVSが「何を満たすか(要件)」。SSDFのプロセスで開発し、ASVSの要件で検証する——この2つは補完関係にあります。
1. 大原則:信頼境界で検証する(ASVS V1/V2)
すべてのセキュアコーディングの出発点は、たった一つの問いです。「この値は、信頼できる内側で生まれたか、信頼できない外側から来たか」。外から来た値(ユーザー入力・API応答・ファイル・環境変数)は、境界でスキーマ検証してから内側に入れます。
TypeScriptのような型安全な言語でも、これは省略できません。型はコンパイル時の約束にすぎず、実行時に外から来るデータが型どおりである保証はないからです。境界ではZodのような実行時バリデータで「検証して、初めて型を得る」。
// 境界バリデーション:外から来たJSONを、信じる前に検証する。
import { z } from "zod";
// ① 受け入れる形を、ホワイトリストとして厳密に宣言する。
const CreateUserSchema = z.object({
email: z.string().email().max(254),
displayName: z.string().min(1).max(50),
age: z.number().int().min(0).max(150),
}).strict(); // ← strict(): 想定外のキーを拒否(マスアサインメント対策)
export async function POST(req: Request): Promise<Response> {
const json: unknown = await req.json(); // ② 外から来た値は常に unknown 型で受ける
const parsed = CreateUserSchema.safeParse(json); // ③ 検証してから内側へ
if (!parsed.success) {
// ④ エラー詳細は最小限に。内部構造を攻撃者に教えない。
return Response.json({ error: "Invalid input" }, { status: 400 });
}
// ⑤ ここから先、parsed.data は「検証済みの型」として安全に使える。
const user = await createUser(parsed.data);
return Response.json({ id: user.id }, { status: 201 });
}
ポイントは .strict() です。スキーマにないキーを拒否することで、isAdmin: true のような想定外フィールドを紛れ込ませるマスアサインメント攻撃を構造的に断てます。「来た値をそのまま展開して保存」が最も危険なアンチパターンです。
型安全を“方針”で終わらせず、境界で実行時検証として強制する考え方は、TypeScript 型安全の規律に体系化しています。
2. 認可はサーバー/DBで強制する(ASVS V4・OWASP Top 10 A01)
OWASP Top 10:2025で第1位が「アクセス制御の不備(Broken Access Control)」です。最も多く、最も被害が大きい。原則は2つです。
- 認証(あなたは誰か)と認可(あなたに権限があるか)を分けて、両方を必ず行う。
- 認可をクライアント(UI)に置かない。 ボタンを隠すのは利便性のためで、防御ではありません。攻撃者はAPIを直接叩きます。
// ❌ 危険:URLのIDを信じている。他人のIDに変えれば他人のデータが読める(IDOR)。
const doc = await db.document.findUnique({ where: { id: params.id } });
// ✅ 安全:所有者条件をクエリに焼き込む。他人のものは「存在しない」ことにする。
const doc = await db.document.findFirst({
where: { id: params.id, ownerId: session.userId },
});
if (!doc) return new Response("Not Found", { status: 404 }); // 403ではなく404で存在を隠す
「所有者条件をクエリに焼き込む」のが急所です。「取得してから所有者を確認する」のではなく、「自分のものしか取得できないクエリにする」。後者は、確認を書き忘れても安全側に倒れます。
データベース自身に認可を寄せる 行レベルセキュリティ(RLS) はさらに強力です。アプリのバグがあってもDBが最後の砦になります。具体的な設計はSupabase RLS 本番設計、検出は認可不備・IDORの検出ガイドに。
3. インジェクションと出力エンコード(ASVS V5・Top 10 A05)
インジェクション(SQLi・コマンドインジェクション・XSS)は、「データ」を「コード」として解釈させる攻撃です。防御の原則は普遍的——データとコードを混ぜない。
SQLインジェクション:必ずパラメータ化する
// ❌ 危険:文字列連結。' OR '1'='1 のような入力でクエリ構造が壊れる。
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ 安全:パラメータ化クエリ。値は常に「データ」として扱われ、コードにならない。
const rows = await db.query("SELECT * FROM users WHERE email = $1", [email]);
ORM(Prisma / Drizzle 等)を使えば、通常はパラメータ化が自動です。生SQLやRPCを書くときだけ要注意——詳細はSQLインジェクション対策。
XSS:出力時にエンコードする
XSSは「ユーザー入力がHTMLとして実行される」攻撃です。Reactは既定でJSX内の値をエスケープするため、{userInput} は安全です。危険なのは、その安全装置を自分で外すときだけ。
// ❌ 危険:dangerouslySetInnerHTML に未検証のHTMLを渡す=XSSの入口。
<div dangerouslySetInnerHTML={{ __html: userProvidedHtml }} />
// ✅ 安全:どうしてもHTMLを描画するなら、信頼できるサニタイザを通す。
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userProvidedHtml) }} />
// ✅ 最も安全:そもそもHTMLとして描画せず、テキストとして渡す(Reactが自動エスケープ)。
<div>{userProvidedText}</div>
判断基準はシンプルです。「dangerouslySetInnerHTML を書こうとしている自分」を疑うこと。詳細はXSS / DOM-based XSS 対策。
4. 防御をデフォルトに:セキュリティヘッダーとCSP(ASVS V13)
優れたセキュリティエンジニアは、「うっかり危険になる」のではなく「うっかりしても安全」な構造を作ります。その代表が、HTTPレスポンスヘッダーによる多層防御です。1枚のミドルウェアで、アプリ全体に防御の床を敷けます。
// middleware.ts — アプリ全体に「安全な既定値」を敷く(Next.js)。
import { NextResponse, type NextRequest } from "next/server";
export function middleware(_req: NextRequest): NextResponse {
const res = NextResponse.next();
// クリックジャッキング防止:フレーム埋め込みを禁止。
res.headers.set("X-Frame-Options", "DENY");
// MIMEスニッフィング防止:Content-Typeを尊重させる。
res.headers.set("X-Content-Type-Options", "nosniff");
// 常にHTTPSを強制(1年・サブドメイン込み)。
res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
// リファラからのパス・クエリ漏えいを抑える。
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
// 不要なブラウザ機能を既定で無効化(最小権限)。
res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
// CSP:許可したオリジン以外のスクリプト実行を禁止(XSSの被害を大幅に軽減)。
res.headers.set(
"Content-Security-Policy",
"default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
);
return res;
}
export const config = { matcher: "/:path*" }; // 全ルートに適用
CSP(Content-Security-Policy)は、XSSが万一すり抜けても**「許可していない場所のスクリプトは動かさない」**第二の壁になります。nonceを使った厳格なCSPの本番運用はセキュリティヘッダー・CSPの実装に。
5. 秘密情報と依存関係(SSDF PS・Top 10 A02/A03)
秘密情報:コードに書かない・型付き境界で扱う
APIキー・DB認証情報・署名鍵は、絶対にコードやリポジトリに書かない。環境変数で注入し、しかも**「サーバー専用の秘密」と「クライアントに出てよい公開値」を型で隔てる**のが堅牢です。
// env.ts — 環境変数も「外から来る値」。起動時に境界で検証する。
import { z } from "zod";
// サーバー専用(クライアントへ絶対に漏らさない)。
const ServerEnvSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
// クライアントへ露出してよい公開値だけを別スキーマに分離する。
const PublicEnvSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url(),
});
// 起動時に検証:設定ミスは「本番で漏れる」前に「起動で落として」気づく。
export const serverEnv = ServerEnvSchema.parse(process.env);
export const publicEnv = PublicEnvSchema.parse(process.env);
「NEXT_PUBLIC_ を付けた秘密鍵がバンドルに焼き込まれて漏える」は頻発する事故です。型で隔てておけば、誤って公開側に秘密を置く設計を“しにくく”できます(→ 環境変数からの秘密漏えい防止)。
依存関係:自分で書いていないコードも“あなたの責任”
現代のアプリは、コードの大半が依存パッケージです。そこに既知の脆弱性があれば、あなたのアプリの脆弱性になります(Top 10 A03「ソフトウェアサプライチェーンの障害」)。依存の脆弱性スキャン(SCA)を自動化し、更新を継続的に当てる仕組みが必須です。GitHubならDependabotが標準解になります。
6. SSDFをCIに焼き込む — 危険なコードをmainに入れない
ここまでの原則を、人間のレビューに依存せず自動で強制するのが最後の仕上げです。SSDFのPW(よく守られたソフトウェアを生む)・PS(ソフトウェアの保護)を、GitHub Actionsの品質ゲートとして実装します。PRごとに自動で回り、ひとつでも危険があればマージをブロックします。
# .github/workflows/security-gate.yml
# SSDFの実践(依存スキャン・SAST・秘密情報スキャン)をPRの必須ゲートにする。
name: security-gate
on:
pull_request:
branches: [main]
permissions:
contents: read # 最小権限:このジョブが必要なのは読み取りだけ
security-events: write # SAST結果(SARIF)をSecurityタブへ送るためだけに付与
jobs:
secure-coding-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ① 依存の脆弱性スキャン(SCA / SSDF: PW.4, RV)。既知CVEを持つ依存で落とす。
- name: Audit dependencies
run: npm audit --audit-level=high
# ② 静的解析(SAST / SSDF: PW.7, PW.8)。インジェクション・認可漏れ等のパターンを検出。
- name: Static analysis (Semgrep)
uses: semgrep/semgrep-action@v1
with:
config: p/owasp-top-ten # OWASP Top 10 のルールセット
# ③ 秘密情報スキャン(SSDF: PS.3)。鍵やトークンの混入をマージ前に止める。
- name: Secret scanning (Gitleaks)
uses: gitleaks/gitleaks-action@v2
# ④ 型・テスト(壊れた契約と退行を止める)。
- name: Type-check and test
run: |
npm ci
npm run type-check
npm test
このゲートが入ると、セキュリティは「リリース直前にまとめてやる特別な工程」から、**「PRごとに自動で効く日常の制約」**に変わります。これがDevSecOpsの核心であり、SSDFが目指す姿です。
正直な線引き: この自動ゲートが強いのは、機械的に検出できる“横の統制”——依存の脆弱性・既知の危険パターン・秘密情報の混入です。一方、「この認可ロジックは事業ルールとして正しいか」「このテナント分離に抜けはないか」という“縦のリスク”は、ツールでは判定できません。そこは人間の設計レビュー/監査の領域です。何が自動化でき、何ができないかの境界はセキュリティ監査は何を見るのかに正直にまとめています。Next.js × Supabase構成のCIゲート実装(SARIF連携まで)はセキュリティCI・SARIFに。
7. ASVSをチェックリストとして使う
最後に、ここまでの実装が「十分か」を測る物差しが ASVS 5.0 です。使い方はシンプルです。
- アプリの機密度からレベルを選ぶ。 一般的なSaaSならレベル2が目安。
- 該当章の要件を、PRやリリースのチェックリストにする。 認証(V6)、セッション管理、アクセス制御(V4)、入力検証(V2)、暗号、ログ…と章ごとに「満たしているか」を確認する。
- 満たせない項目は、リスクとして記録する。 すべてを完璧にする必要はありません。**「何を守れていて、何を守れていないかを把握している」**ことが、把握していないことより決定的に重要です。
ASVSは「全部やれ」という命令書ではなく、**「抜けを可視化する地図」**として使うのが正しい姿勢です。
8. よくある質問(FAQ)
Q. 言語が違っても、この原則は通用しますか? A. はい。コード例はTypeScriptですが、信頼境界での検証・認可のサーバー強制・パラメータ化・出力エンコード・秘密情報をコードに書かないは、Go・Python・Java・Rubyすべてに共通する普遍原則です。SSDFもASVSも言語非依存です。
Q. ORMを使えばSQLインジェクションは気にしなくていい? A. 通常のクエリは自動でパラメータ化されるので安全です。ただし生SQL・ストアドプロシージャ・RPC・動的なORDER BYなどORMの外に出る箇所は要注意です。「ORMを使っているから安全」と思考停止せず、生SQLを書く箇所だけは必ずパラメータ化を確認してください。
Q. SSDFとASVS、どちらから学ぶべきですか? A. 実装者ならASVSの要件(何を満たすか)から入ると具体的で掴みやすいです。チームやプロセスを設計する立場なら**SSDF(どう作るか)**が効きます。両方を行き来するのが理想です。
Q. CIゲートを入れると開発が遅くなりませんか? A. 短期的にはPRが少し止まります。しかし、**脆弱性は「後で見つかるほど修正コストが跳ね上がる」**ため、PR時点で止めるほうが圧倒的に安くつきます。最初はゲートを「警告のみ」で導入し、チームが慣れたら「ブロック」に上げる段階導入が現実的です。
Q. 全部やる時間がありません。優先順位は? A. OWASP Top 10の順、つまり①アクセス制御(認可)②セキュリティ設定(ヘッダー/秘密情報)③インジェクションから。被害が大きく頻度も高い順です。完璧を目指さず、ここから着実に。
9. まとめ
セキュアコーディングは、才能でも気合いでもなく、**公式フレームワークに沿った“仕組み”**です。
- **SSDF(どう作るか)と ASVS(何を満たすか)**を地図にする。車輪を再発明しない。
- 信頼境界で検証する。 外から来た値はすべてスキーマ検証してから内側へ(
.strict()でマスアサインメントも断つ)。 - 認可はサーバー/DBで強制する。 所有者条件をクエリに焼き込み、見つからなければ404で存在を隠す。
- データとコードを混ぜない。 インジェクションはパラメータ化、XSSは出力エンコードで原理的に断つ。
- 防御をデフォルトに。 セキュリティヘッダー/CSP、秘密情報の型付き境界、最小権限。
- CIで自動強制する。 依存スキャン・SAST・秘密情報スキャンを品質ゲートにし、人間のレビュー漏れに依存しない。
そして覚えておいてください。自動化で守れるのは“横の統制”までです。事業ルールに根ざした認可やテナント分離といった**“縦のリスク”は、人間の設計判断**が要ります。だからこそ、横は仕組みで一掃し、縦に人間の時間を集中させる——それが、限られたリソースで最大の安全を得る、セキュリティエンジニアの実践知です。