Web セキュリティ コピペで使える実装 Tips 集【中級者向け】
Web セキュリティのコピペで使える防御実装をまとめました。実務で「どう書けばいいか」に迷いがちな入力バリデーション・CSP・JWT・CORS・パスワードハッシュ・セキュリティヘッダーを TypeScript / Node.js 中心に解説します。
入力バリデーション(zod / valibot)
zod でスキーマ定義 + 型推論
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1).max(100).trim(),
email: z.string().email().toLowerCase(),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "user", "viewer"]),
});
type User = z.infer<typeof UserSchema>;
function createUser(input: unknown): User {
return UserSchema.parse(input);
}
parse は検証失敗時に ZodError をスローします。API ハンドラでは safeParse を使うとエラーを握りつぶさずに済みます。
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const user = result.data;
valibot で軽量バリデーション
バンドルサイズを削りたいときは valibot が選択肢に入ります(zod の約 1/8 サイズ)。
import * as v from "valibot";
const PasswordSchema = v.pipe(
v.string(),
v.minLength(12),
v.regex(/[A-Z]/, "大文字を1文字以上含めてください"),
v.regex(/[0-9]/, "数字を1文字以上含めてください"),
v.regex(/[^A-Za-z0-9]/, "記号を1文字以上含めてください"),
);
const parsed = v.safeParse(PasswordSchema, input);
SQL インジェクション対策(Prepared Statement)
ORM を使わない場合でも必ず Prepared Statement を使います。
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
async function getUserById(id: string) {
const rows = await sql`SELECT * FROM users WHERE id = ${id}`;
return rows[0] ?? null;
}
テンプレートリテラルタグ関数が自動的にパラメータ化します。文字列結合で SQL を組み立ててはいけません。
Content Security Policy(CSP)ヘッダー設定
Hono / Express でのヘッダー設定
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
const app = new Hono();
app.use(
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'strict-dynamic'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.example.com"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
})
);
nonce ベースの CSP(Next.js Middleware)
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";
export function middleware(request: NextRequest) {
const nonce = randomBytes(16).toString("base64");
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: blob:`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join("; ");
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", csp);
response.headers.set("x-nonce", nonce);
return response;
}
JWT の安全な扱い方
httpOnly Cookie への格納
localStorage への JWT 保存は XSS で盗まれます。httpOnly かつ Secure な Cookie に格納します。
import { SignJWT, jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
async function signToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(SECRET);
}
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, SECRET);
return payload;
}
function setAuthCookie(res: Response, token: string) {
res.headers.set(
"Set-Cookie",
[
`token=${token}`,
"HttpOnly",
"Secure",
"SameSite=Strict",
"Path=/",
"Max-Age=900",
].join("; ")
);
}
リフレッシュトークンのローテーション
type TokenPair = { accessToken: string; refreshToken: string };
async function rotateTokens(
oldRefreshToken: string,
db: DB
): Promise<TokenPair> {
const session = await db.session.findFirst({
where: { refreshToken: oldRefreshToken, revokedAt: null },
});
if (!session) throw new Error("Invalid refresh token");
await db.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
const accessToken = await signToken(session.userId);
const refreshToken = crypto.randomUUID();
await db.session.create({
data: { userId: session.userId, refreshToken, expiresAt: add30Days() },
});
return { accessToken, refreshToken };
}
リフレッシュトークンは使用のたびに新しいものに差し替え、古いものを無効化します。
CORS の正しい設定
許可オリジンをホワイトリスト管理
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
function corsMiddleware(req: Request, res: Response, next: NextFunction) {
const origin = req.headers["origin"];
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
res.sendStatus(204);
return;
}
next();
}
Access-Control-Allow-Origin: * にすると認証情報付きリクエストが使えず、かつオープンになりすぎます。オリジンを動的に制御して Vary: Origin を必ず付与します。
Hono での CORS 設定
import { cors } from "hono/cors";
app.use(
cors({
origin: (origin) =>
ALLOWED_ORIGINS.has(origin ?? "") ? origin : undefined,
allowMethods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 86400,
})
);
パスワードハッシュ(bcrypt / Argon2)
Argon2id(推奨)
Argon2id は bcrypt より GPU 耐性が高く、2023 年以降の新規実装では第一選択です。
import { hash, verify } from "@node-rs/argon2";
const ARGON2_OPTIONS = {
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
outputLen: 32,
};
async function hashPassword(plain: string): Promise<string> {
return hash(plain, ARGON2_OPTIONS);
}
async function verifyPassword(plain: string, hashed: string): Promise<boolean> {
return verify(hashed, plain, ARGON2_OPTIONS);
}
bcrypt(既存システムとの互換が必要な場合)
import bcrypt from "bcryptjs";
const ROUNDS = 12;
async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, ROUNDS);
}
async function verifyPassword(plain: string, hashed: string): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}
ROUNDS は 10〜12 が現実的な選択です。大きすぎるとログイン処理で数秒かかります。ハッシュ化前に文字数を 72 バイト以内に制限するか Argon2 に移行することを推奨します。
セキュリティヘッダー一覧
nginx での設定
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
Node.js / Hono での設定
import { secureHeaders } from "hono/secure-headers";
app.use(
secureHeaders({
strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
xFrameOptions: "DENY",
xContentTypeOptions: "nosniff",
referrerPolicy: "strict-origin-when-cross-origin",
permissionsPolicy: {
camera: [],
microphone: [],
geolocation: [],
},
crossOriginOpenerPolicy: "same-origin",
crossOriginResourcePolicy: "same-origin",
})
);
HTTP レスポンスヘッダー確認コマンド
curl -sI https://example.com | grep -iE "(strict-transport|x-frame|x-content|referrer|permissions|content-security)"
まとめ
実装時にすぐ確認できるよう、各 Tip のポイントを整理します。
| カテゴリ | ライブラリ / 手法 | 押さえるポイント |
|---|---|---|
| 入力バリデーション | zod / valibot | safeParse でエラーを適切に返す。Prepared Statement は必須 |
| CSP | Hono secureHeaders / Next.js Middleware | nonce を使い unsafe-inline を排除。frame-ancestors: 'none' で Clickjacking 対策 |
| JWT | jose + httpOnly Cookie | localStorage 禁止。有効期限は 15 分以内。リフレッシュトークンはローテーション |
| CORS | ホワイトリスト制御 | * は使わない。Vary: Origin を付与。Preflight に Max-Age で帯域節約 |
| パスワードハッシュ | Argon2id(推奨)/ bcrypt | 新規は Argon2id。bcrypt は ROUNDS 12 以上。平文を比較してはいけない |
| セキュリティヘッダー | nginx / Hono | HSTS・X-Frame-Options・X-Content-Type-Options は最低限セット |
これらはどれも「やっていなかったら即問題になる」類の対策です。既存プロジェクトのチェックリストとしても活用してください。