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 の安全な扱い方

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 / valibotsafeParse でエラーを適切に返す。Prepared Statement は必須
CSPHono secureHeaders / Next.js Middlewarenonce を使い unsafe-inline を排除。frame-ancestors: 'none' で Clickjacking 対策
JWTjose + httpOnly CookielocalStorage 禁止。有効期限は 15 分以内。リフレッシュトークンはローテーション
CORSホワイトリスト制御* は使わない。Vary: Origin を付与。Preflight に Max-Age で帯域節約
パスワードハッシュArgon2id(推奨)/ bcrypt新規は Argon2id。bcrypt は ROUNDS 12 以上。平文を比較してはいけない
セキュリティヘッダーnginx / HonoHSTS・X-Frame-Options・X-Content-Type-Options は最低限セット

これらはどれも「やっていなかったら即問題になる」類の対策です。既存プロジェクトのチェックリストとしても活用してください。