Teams where secure coding doesn't go well have something in common. They depend on human carefulness — "be careful," "notice in review." Carefulness tires. It fades before a deadline. Newcomers don't have it. So, turning safety from "individual effort" into a "mechanism" — that's the theme of this article.
Fortunately, there's no need to reinvent the wheel. NIST and OWASP officially systematize how to build safely. This article, with these two primary sources as a map, shows the principles of secure coding in real code you can copy and use, and finally runs it through to auto-enforcing it in CI.
The relationship of this article and its sister article: "what role to aim for in the first place" is in how to become a security engineer [complete roadmap]. This article digs into its core skill, "the technique of building safely." The code examples are TypeScript/Next.js-centric, but the principles are language-independent. Concrete vulnerability detection/fixing on a specific stack (Next.js × Supabase) is linked to individual articles in various places.
0. Two official frameworks — SSDF and ASVS
First, grasp the two maps we'll reference many times.
NIST SSDF (Secure Software Development Framework / SP 800-218)
SSDF (SP 800-218 v1.1) is a development-process framework that organizes the "how to build" of safe software into 4 practice groups (v1.2 was published as a draft in December 2025 and is in public comment).
| Practice group | What it does |
|---|---|
| PO (Prepare the Organization) | preparation to build safely as an organization (policies, roles, tooling) |
| PS (Protect the Software) | protect the software itself and the code (tamper prevention, integrity, secret protection) |
| PW (Produce Well-Secured Software) | produce well-secured software (design, secure coding, review, test) |
| RV (Respond to Vulnerabilities) | respond to vulnerabilities (discovery, fixing, recurrence prevention) |
This article's §1–§5 are mainly PW (write safely), §6 is the automation of PS/PW, and §7 is the entrance to RV.
OWASP ASVS 5.0 (Application Security Verification Standard)
ASVS 5.0.0 (published May 2025, dedicated site asvs.dev) is a checklist of "requirements a safe app should satisfy." It organizes about 350 verification requirements into 17 chapters, and you can choose "how far to protect" with 3 levels.
- Level 1: minimum (the starting point for many apps)
- Level 2: the recommended level for most apps handling sensitive data
- Level 3: apps needing the highest level, like medical and financial
SSDF is "how to build (process)," ASVS is "what to satisfy (requirements)." Develop with SSDF's process and verify with ASVS's requirements — the two are complementary.
1. The big principle: validate at the trust boundary (ASVS V1/V2)
The starting point of all secure coding is a single question. "Was this value born inside the trustworthy, or did it come from outside the untrustworthy?" A value from outside (user input, API response, file, environment variable) is schema-validated at the boundary before entering the inside.
Even in a type-safe language like TypeScript, this can't be omitted. The type is just a compile-time promise, and there's no guarantee that data coming from outside at runtime matches the type. At the boundary, with a runtime validator like Zod, "validate, and only then get the type."
// 境界バリデーション:外から来た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 });
}
The point is .strict(). By rejecting keys not in the schema, you can structurally cut the mass-assignment attack that smuggles in an unexpected field like isAdmin: true. "Expand the incoming value as-is and save it" is the most dangerous antipattern.
The idea of not ending type safety as a "policy" but enforcing it as runtime validation at the boundary is systematized in the discipline of TypeScript type safety.
2. Enforce authorization on the server/DB (ASVS V4, OWASP Top 10 A01)
In OWASP Top 10:2025, #1 is "Broken Access Control." The most common and the most damaging. There are 2 principles.
- Separate authentication (who you are) and authorization (whether you have permission), and always do both.
- Don't put authorization in the client (UI). Hiding a button is for convenience, not defense. Attackers hit the API directly.
// ❌ 危険: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で存在を隠す
"Baking the owner condition into the query" is the crux. Not "fetch, then confirm the owner" but "make a query that can only fetch your own." The latter falls back to the safe side even if you forget to write the confirmation.
Row-level security (RLS), which leans authorization onto the database itself, is even stronger. Even with an app bug, the DB becomes the last fort. For concrete design, the Supabase RLS production design; for detection, the broken-authorization / IDOR detection guide.
3. Injection and output encoding (ASVS V5, Top 10 A05)
Injection (SQLi, command injection, XSS) is an attack that makes "data" be interpreted as "code." The defense principle is universal — don't mix data and code.
SQL injection: always parameterize
// ❌ 危険:文字列連結。' 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]);
Use an ORM (Prisma / Drizzle, etc.) and parameterization is usually automatic. Be careful only when writing raw SQL or RPC — for details, SQL-injection measures.
XSS: encode at output time
XSS is an attack where "user input is executed as HTML." React escapes values inside JSX by default, so {userInput} is safe. The danger is only when you remove that safety device yourself.
// ❌ 危険:dangerouslySetInnerHTML に未検証のHTMLを渡す=XSSの入口。
<div dangerouslySetInnerHTML={{ __html: userProvidedHtml }} />
// ✅ 安全:どうしてもHTMLを描画するなら、信頼できるサニタイザを通す。
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userProvidedHtml) }} />
// ✅ 最も安全:そもそもHTMLとして描画せず、テキストとして渡す(Reactが自動エスケープ)。
<div>{userProvidedText}</div>
The criterion is simple. Doubt "yourself about to write dangerouslySetInnerHTML." For details, XSS / DOM-based XSS measures.
4. Defense by default: security headers and CSP (ASVS V13)
A good security engineer builds a structure that's "safe even if you slip," not one that "becomes dangerous if you slip." The representative is defense-in-depth with HTTP response headers. With one middleware, you can lay a defense floor across the whole app.
// 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) becomes a second wall that "doesn't run scripts from unpermitted places" even if XSS slips through. The production operation of a strict CSP using a nonce is in the implementation of security headers / CSP.
5. Secrets and dependencies (SSDF PS, Top 10 A02/A03)
Secrets: don't write in code, handle at a typed boundary
API keys, DB credentials, and signing keys must never be written in code or the repository. Inject them via environment variables, and what's more robust is to separate "server-only secrets" and "public values that may go to the client" by type.
// 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);
"A secret key prefixed with NEXT_PUBLIC_ gets baked into the bundle and leaks" is a frequent accident. Separate by type and you can make it "hard" to mistakenly design a secret on the public side (→ preventing secret leaks from environment variables).
Dependencies: code you didn't write yourself is also "your responsibility"
In a modern app, most of the code is dependency packages. If there's a known vulnerability there, it becomes your app's vulnerability (Top 10 A03 "Software Supply Chain Failures"). A mechanism to automate dependency vulnerability scanning (SCA) and continuously apply updates is essential. On GitHub, Dependabot is the standard answer.
6. Bake SSDF into CI — don't let dangerous code into main
The final finishing is to auto-enforce the principles so far, without depending on human review. Implement SSDF's PW (produce well-secured software) and PS (protect the software) as a GitHub Actions quality gate. It runs automatically per PR and blocks the merge if there's even one danger.
# .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
With this gate in, security changes from "a special process you do all at once right before release" to "a daily constraint that auto-applies per PR." This is the heart of DevSecOps and the figure SSDF aims for.
An honest line: where this auto-gate is strong is the mechanically-detectable "horizontal controls" — dependency vulnerabilities, known dangerous patterns, and secret intrusion. On the other hand, "vertical risks" like "is this authorization logic correct as a business rule" and "is there a gap in this tenant separation" can't be judged by tools. That's the domain of human design review/audit. The boundary of what can and can't be automated is honestly summarized in what a security audit looks at. The CI-gate implementation (through SARIF integration) for a Next.js × Supabase configuration is in security CI / SARIF.
7. Use ASVS as a checklist
Finally, the yardstick to measure whether the implementation so far is "enough" is ASVS 5.0. The usage is simple.
- Choose the level from the app's sensitivity. For a general SaaS, level 2 is the guide.
- Make the relevant chapter's requirements a checklist for PRs and releases. Confirm "is it satisfied" per chapter: authentication (V6), session management, access control (V4), input validation (V2), cryptography, logging…
- Record items you can't satisfy as risks. You don't need everything perfect. "Grasping what you're protecting and what you're not" is decisively more important than not grasping it.
The correct stance is to use ASVS not as a command document of "do everything" but as "a map that visualizes gaps."
8. Frequently asked questions (FAQ)
Q. Do these principles apply even if the language differs? A. Yes. The code examples are TypeScript, but validation at the trust boundary, server-enforced authorization, parameterization, output encoding, and not writing secrets in code are universal principles common to Go, Python, Java, and Ruby. Both SSDF and ASVS are language-independent.
Q. If I use an ORM, do I not need to worry about SQL injection? A. Ordinary queries are auto-parameterized so they're safe. But be careful with spots that go outside the ORM, like raw SQL, stored procedures, RPC, and dynamic ORDER BY. Don't stop thinking with "I'm using an ORM so it's safe"; for spots where you write raw SQL, always confirm parameterization.
Q. SSDF or ASVS — which to learn first? A. If you're an implementer, entering from ASVS's requirements (what to satisfy) is concrete and easy to grasp. If you're in a position to design teams and processes, SSDF (how to build) works. Going back and forth between both is ideal.
Q. Doesn't a CI gate slow development? A. In the short term, PRs stop a bit. But because a vulnerability's fix cost skyrockets the later it's found, stopping it at PR time is overwhelmingly cheaper. A realistic staged introduction is to first introduce the gate as "warning only" and raise it to "block" once the team is used to it.
Q. I don't have time to do everything. What's the priority? A. The OWASP Top 10 order, i.e., ① access control (authorization) ② security configuration (headers/secrets) ③ injection first. In order of large damage and high frequency. Don't aim for perfection; start steadily from here.
9. Conclusion
Secure coding is neither talent nor willpower but a "mechanism" along official frameworks.
- Use SSDF (how to build) and ASVS (what to satisfy) as a map. Don't reinvent the wheel.
- Validate at the trust boundary. Schema-validate every value from outside before the inside (cut mass assignment with
.strict()too). - Enforce authorization on the server/DB. Bake the owner condition into the query, and hide existence with 404 if not found.
- Don't mix data and code. Cut injection in principle with parameterization and XSS with output encoding.
- Defense by default. Security headers/CSP, a typed boundary for secrets, least privilege.
- Auto-enforce in CI. Make dependency scan, SAST, and secret scan a quality gate and don't depend on human review gaps.
And remember. What automation can protect is only "horizontal controls." "Vertical risks" like authorization and tenant separation rooted in business rules need human design judgment. That's exactly why — sweep the horizontal with a mechanism, and concentrate human time on the vertical — that's the practical wisdom of a security engineer to get the maximum safety with limited resources.
References (official primary sources)
- Frameworks: NIST SSDF (SP 800-218) / OWASP ASVS 5.0 (asvs.dev)
- Standards: OWASP Top 10:2025 / OWASP Cheat Sheet Series
- Tools: Semgrep / Gitleaks / Zod
- Related: how to become a security engineer (roadmap)