1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js セキュリティ徹底ガイド — 初心者がフロントエンドで押さえるべき10の境界

1
Last updated at Posted at 2026-06-15

はじめに

「Next.js のセキュリティ」と聞いて、まず何から手をつければよいか分かりますか?

XSS、CSRF、Open Redirect、CSP、HttpOnly Cookie、NEXT_PUBLIC_、Server Actions…キーワードは山ほど出てきますが、初心者が一番つまずくのは 「Next.js 特有の落とし穴がどこにあるのか」が見えないこと です。Next.js は Server Component と Client Component が同居するフレームワークなので、Rails や Express のような従来の MVC とは違う「サーバーとクライアントが地続きであるがゆえの罠」が存在します。

本記事では、Next.js (App Router) で開発を始める初心者のために、フロントエンドで押さえておくべきセキュリティの「境界」とその対策を10個に整理 しました。それぞれ「なぜそうするのか」を必ず添えています。

前提

  • 対象: Next.js App Router を使い始めた初心者〜中級者
  • スタック: Next.js / TypeScript / Zod、バックエンドは Laravel など HTTP API を返すサーバー
  • スコープ: フロントエンドのコードで完結する対策のみ を扱います。WAF、TLS、レート制限、IP 制限といったインフラ側の話は別記事に譲ります
  • 筆者の立場: バックエンドが別にある(SPA + API)構成を前提にしています。Next.js だけで認証を完結させる場合は適宜読み替えてください

大前提: セキュリティは「境界」で考える

セキュリティ対策は雑多に並べると覚えきれません。Next.js のフロントエンドで起きる脅威は、「どこに信頼の境界線が引かれているか」 で整理すると見通しが良くなります。

# 境界 主な脅威 主な対策
1 環境変数とバンドル 機密情報の漏洩 NEXT_PUBLIC_ の使い分け / Zod 検証 / server-only
2 Server Component → Client Component データの過剰露出 Props を Zod でフィルタリング
3 クライアント → Server Action / Route Handler 不正リクエスト 認証・認可・入力バリデーション
4 サーバーが知っているセッション ↔ ブラウザの保持領域 トークン盗難 HttpOnly Cookie
5 ユーザー入力 → DOM レンダリング XSS 自動エスケープを壊さない
6 外部サイト → 自サイトの状態変更 CSRF Origin 検証 / Same-Site Cookie
7 URL パラメータ → リダイレクト先 Open Redirect ホワイトリスト検証
8 HTTP レスポンス クリックジャック・MIME スニッフィング等 セキュリティヘッダー
9 アプリ内エラー → ユーザー表示 内部情報の漏洩 汎用メッセージ
10 自前コード ↔ npm パッケージ 依存の脆弱性 npm audit / Dependabot

以下、この順番で1つずつ見ていきます。各章は独立しているので、気になるところから読んで構いません。


1. 環境変数とバンドルの境界 — NEXT_PUBLIC_ の有無で世界が分かれる

なぜ気をつける必要があるのか

Next.js では、環境変数の名前に NEXT_PUBLIC_ を付けるかどうかで クライアントの JavaScript バンドルに含まれるかどうか が決まります。これは「サーバーで実行されるコード」と「ブラウザに送られるコード」が同じファイルに同居しうる Next.js 特有の事情から生まれた仕様です。

つまり、NEXT_PUBLIC_API_SECRET_KEY のような名前を付けてしまうと、ビルド時に値がそのままブラウザの JavaScript に埋め込まれ、F12 で誰でも見られる状態になります。これは「漏洩しやすい」ではなく「公開している」と同じです。

対策

// ✅ サーバー専用(API シークレット、DB 認証情報など)
const apiKey = process.env.API_SECRET_KEY;

// ✅ クライアントでも参照可能(公開 URL など)
const publicUrl = process.env.NEXT_PUBLIC_API_URL;

// ❌ 絶対 NG: 機密情報に NEXT_PUBLIC_ を付ける
const apiKey = process.env.NEXT_PUBLIC_API_SECRET_KEY;

Zod スキーマで起動時に検証する

環境変数を process.env.FOO でそのまま参照すると、設定漏れに気づくのが「実行中に undefined が混じった URL を fetch した時」 になってしまいます。これは最悪のパターンです。

そこで Zod で起動時に検証しておきます。

// src/shared/config/env.ts
import { z } from "zod";

const envSchema = z.object({
  APP_ENV: z.enum(["local", "development", "staging", "production"]).default("production"),
  API_SECRET_KEY: z.string(),
  NEXT_PUBLIC_API_URL: z.string().url(),
});

export const env = envSchema.parse(process.env);
// 必須 env が欠けていればここで throw → 起動失敗

ポイント: .optional() は安易に使わない。 未設定でも起動してしまい、本番でだけ undefined になってバグになります。「明示的にデフォルト値を付ける(.default())」か「必須にする(z.string())」かのどちらかにします。optional() を使うのは「未指定時に動的に構築するなど、明確な意図がある場合」だけです。

サーバー専用のモジュールには server-only を付ける

DB アクセスや認証ロジックなど「絶対にクライアントから import されてはいけない」関数には、server-only パッケージをインポートしておきます。

// features/auth/api/getSession.ts
import "server-only";

export async function getSession() {
  // サーバー専用のロジック(クライアントから import するとビルドが落ちる)
}

なぜ? うっかり Client Component から import してしまった場合、ビルド時にエラーになって気づけます。チーム開発では事故防止の効果が大きい設定です。


2. Server Component → Client Component の境界 — Props は HTML に露出する

なぜ気をつける必要があるのか

Server Component で取得したデータを Client Component に Props で渡すと、その Props は シリアライズされて HTML / JavaScript に埋め込まれてブラウザに送られます。サーバー側で「画面に表示しないから安全」と思っていたフィールドも、ブラウザの DevTools で見えてしまいます。

たとえば「ユーザーの内部 ID」「ロール情報」「メールアドレス」など、API のレスポンスをそのまま Client Component に渡すと意図せず公開されることがあります。

対策: 渡す前に Zod スキーマでフィルタする

import { z } from "zod";

// クライアントに渡してよいフィールドだけを定義
const userPublicSchema = z.object({
  id: z.string(),
  name: z.string(),
  avatarUrl: z.string().optional(),
});

async function UserPage() {
  const user = await getUser(); // 内部用フィールドも含む
  const safeUser = userPublicSchema.parse(user); // 公開可能なフィールドだけに絞る
  return <UserProfile user={safeUser} />;
}

ポイント: TypeScript の型だけでは「実行時にどのフィールドが渡るか」は強制できません。Zod の parse余分なフィールドを落とす(strict mode を使えば検出も可能) ので、型と実行時の両方で守れます。


3. Server Actions / Route Handlers の境界 — 「公開 API」として扱う

なぜ気をつける必要があるのか

"use server" でエクスポートした Server Action は、クライアントから直接呼び出せる HTTP エンドポイント になります。Route Handler (app/api/.../route.ts) も同じです。

つまり、「フォームから送信される前提」だとしても、攻撃者は任意のペイロードで直接 POST してきます。「UI 側でバリデーションしてるから大丈夫」は通用しません。

必須の3点セット: 認証 → 認可 → 入力バリデーション

"use server";

import { z } from "zod";
import { auth } from "@/shared/lib/auth";
import { apiClient } from "@/shared/lib/apiClient";
import { redirect } from "next/navigation";

const updateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

export async function updateProfile(formData: FormData) {
  // 1. 認証チェック(ログインしているか)
  const session = await auth();
  if (!session?.user) {
    redirect("/login");
  }

  // 2. 認可チェック(その操作をする権限があるか)
  const targetUserId = formData.get("userId");
  if (session.user.id !== targetUserId && session.user.role !== "admin") {
    throw new Error("権限がありません");
  }

  // 3. 入力バリデーション(送られてきたデータが想定通りか)
  const result = updateProfileSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
  });
  if (!result.success) {
    return { error: result.error.flatten() };
  }

  // 4. 業務ロジック実行
  await apiClient.put(`/api/users/${session.user.id}`, { body: result.data });
}

なぜ「認証」と「認可」を分けるのか?

  • 認証 (Authentication) = 「あなたは誰か」を確認する(ログインの有無)
  • 認可 (Authorization) = 「あなたはこれをやってよいか」を確認する(権限)

たとえばログインしているユーザーが、別の人の userId を渡してきたら? 認証は通っていますが、認可が落ちなければ他人のデータを書き換えられてしまいます。両方が必要 です。

page.tsx の認証・認可は「ガード関数」で集約する

src/app/**/page.tsx の Server Component でも、認証・認可は必須です。ただし、page.tsx ごとに getSession() を呼んで if 文を手書きすると 判定漏れによる権限越境バグ が起きやすくなります(実プロジェクトでも頻発するパターンです)。

そこで「ガード関数」を1ファイルに集約しておくのがおすすめです。

// src/shared/lib/session/guards.ts
export async function requireSuperUser() {
  const session = await getSession();
  if (!session) return { session: null };
  if (!session.isSuperUser) notFound(); // 権限不足は 404
  return { session };
}

page 側はこう書きます。

// ✅ 推奨
export default async function AdminRoute() {
  const auth = await requireSuperUser();
  if (!auth.session) return redirectToLogin();

  const data = await fetchSomething(auth.session.clientId);
  return <AdminPage data={data} />;
}
// ❌ NG: 手書きの role 判定はチェック漏れの温床
const session = await getSession();
if (!session) return redirectToLogin();
if (!session.isSuperUser) notFound(); // ← これを書き忘れると別ロールでも入れてしまう

**ポイント: 未認証は「ログインへリダイレクト」、権限不足は「404 (notFound)」と分ける。**未認証で 404 を出してしまうと「URL を間違えたのか?」と利用者が混乱します。ロール越境は 404 にすることで「リソースの存在自体を秘匿する」効果もあります。


4. 認証トークンの境界 — 保存場所で安全性が決まる

なぜ気をつける必要があるのか

ログイン後のセッショントークン(または JWT)をどこに保存するかで、XSS が起きた時にどれだけ被害が広がるか が決まります。

保存場所 XSS への耐性 リロード耐性 推奨度
HttpOnly Cookie ◎ JavaScript から読めない ✅ 推奨
localStorage ✕ JS から全部読める ❌ 非推奨
sessionStorage ✕ JS から全部読める △ タブを閉じると消える ❌ 非推奨
メモリ(状態管理) ◎ DOM 経由でなければ読めない ✕ リロードで消える △ 短期用途のみ

なぜ localStorage が危険なのか? localStorage は同一オリジンの JavaScript なら誰でも読み書きできます。1箇所でも XSS を許してしまうと、トークンが全部抜かれます。

HttpOnly Cookie は何が違うのか? HttpOnly フラグが付いた Cookie は JavaScript から document.cookie でアクセスできません。XSS でスクリプトが動いても、トークン本体は奪えません(ただし「Cookie を勝手に送信させる」CSRF の話は別。次章参照)。

推奨パターン: バックエンドが HttpOnly Cookie を発行、Next.js は自動送信に乗る

Laravel など API サーバー側で Set-Cookie ヘッダーに HttpOnly; Secure; SameSite=Lax を付けて発行し、Next.js からは credentials: "include" で自動的に Cookie を載せます。

const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/user`, {
  credentials: "include", // Cookie を自動送信
});

やむを得ず localStorage を使う場合の最低限ルール: リフレッシュトークン(長期セッション用)は絶対に localStorage に置かない。漏れた瞬間に永続的なアカウント乗っ取りになります。


5. ユーザー入力 → DOM の境界 — XSS は React の自動エスケープを「壊さない」

なぜ気をつける必要があるのか

XSS (Cross-Site Scripting) は「ユーザーが入力した文字列が HTML として解釈されてしまう」脆弱性です。<script>alert(1)</script> を入力欄に入れたら本当にアラートが出る、というあれです。

React は JSX 内で {value} の形で挿入される値を自動的にエスケープ してくれます。つまり、何もしなければ XSS は起きにくいフレームワークです。

// ✅ 自動エスケープされるので安全
<p>{userInput}</p>

問題は、React の自動エスケープを わざわざ無効化する API が存在することです。それが dangerouslySetInnerHTML です。名前の通り、危険です。

// ❌ XSS 脆弱性そのもの
<div dangerouslySetInnerHTML={{ __html: userInput }} />

やむを得ず HTML を挿入する場合: DOMPurify でサニタイズ

ブログ記事のリッチテキスト表示など、どうしても HTML として描画したいケースはあります。その場合は 必ずサニタイズライブラリを通します

import DOMPurify from "isomorphic-dompurify";

const sanitizedHtml = DOMPurify.sanitize(untrustedHtml);
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />

ポイント: 「自前で <script> をフィルタする正規表現を書く」のは絶対にやめてください。XSS のバイパス手法は何百種類もあり、自前ロジックでは必ず抜けます。


6. 外部サイトからの境界 — CSRF は「自動送信される Cookie」が前提

なぜ気をつける必要があるのか

CSRF (Cross-Site Request Forgery) は「ログインしているユーザーのブラウザに、意図しないリクエストを送らせる」攻撃です。攻撃者のサイトに <img src="https://your-app.com/api/transfer?to=attacker&amount=10000"> のようなタグを仕込んでおき、被害者がそのページを開くだけで、被害者の Cookie が自動付与された状態でリクエストが飛びます。

ポイントは 「自動付与される Cookie」が攻撃の前提 であること。HttpOnly Cookie でトークンを守っていても、CSRF とは別問題です。

Next.js 14+ の Server Actions は自動保護される

Server Actions については、Next.js 14 以降で以下の保護が自動的に有効になっています:

  • Same-Site Cookie: デフォルトで Cookie 自体がクロスサイトには送られない
  • Origin ヘッダー検証: リクエスト元のオリジンが自サイトと一致するかを検証

したがって、Server Action を使っている限り、CSRF 対策は基本的に意識しなくてよい です(これが Server Actions の大きな利点の1つ)。

Route Handler は手動で Origin 検証が必要

app/api/.../route.ts を自前で書く場合は、Next.js の自動保護は効きません。明示的に Origin ヘッダーを検証します。

import { headers } from "next/headers";

export async function POST(request: Request) {
  const headersList = await headers();
  const origin = headersList.get("origin");
  const host = headersList.get("host");

  if (!origin || !origin.includes(host || "")) {
    return new Response("Forbidden", { status: 403 });
  }

  // 処理を続行
}

ポイント: Cookie の SameSite=LaxStrict を設定するのが最初の防壁です。SameSite=None は外部サイトからの送信を許可してしまうので、明確な理由(クロスドメイン埋め込みなど)がなければ使わないこと。


7. URL パラメータ → リダイレクトの境界 — Open Redirect でフィッシングに悪用される

なぜ気をつける必要があるのか

ログイン後の遷移先を ?returnUrl= のようなクエリパラメータで指定する設計はよくあります。

// ❌ 危険
const returnUrl = searchParams.get("returnUrl");
router.push(returnUrl || "/");

これだと、攻撃者が https://your-app.com/login?returnUrl=https://evil.example.com/fake-login のような URL を作って配布した場合、被害者は「自社ドメインの URL だから安全」と思ってログインし、ログイン後に 外部の偽サイトに飛ばされて再ログインを求められる という被害につながります。これが Open Redirect です。

対策: ホワイトリストで検証

const ALLOWED_HOSTS = ["your-app.com", "www.your-app.com"];

export function isSafeRedirectUrl(url: string): boolean {
  try {
    const parsed = new URL(url, window.location.origin);
    // 相対パスは許可
    if (parsed.origin === window.location.origin) return true;
    // 許可リストに含まれるホストのみ許可
    return ALLOWED_HOSTS.includes(parsed.hostname);
  } catch {
    return false;
  }
}

// 使用側
if (returnUrl && isSafeRedirectUrl(returnUrl)) {
  router.push(returnUrl);
} else {
  router.push("/");
}

ポイント: returnUrl.startsWith("/") で判定するのは 不十分です//evil.example.com のような Protocol-relative URL でバイパスされます。必ず new URL() でパースして origin を見ること。


8. HTTP レスポンスの境界 — セキュリティヘッダーは多層防御の要

なぜ気をつける必要があるのか

セキュリティヘッダーは、たとえアプリ側のコードに穴があったとしても 「ブラウザ側で被害を最小化する」最後の防壁 です。各ヘッダーは独立した脅威に対応しているので、複数組み合わせる「多層防御」の発想で設定します。

主要なヘッダーと役割

ヘッダー 役割 これがないと?
Strict-Transport-Security HTTPS を強制 HTTP にダウングレードする中間者攻撃を許す
X-Frame-Options: DENY iframe 埋め込み禁止 クリックジャッキングを許す
X-Content-Type-Options: nosniff MIME スニッフィング禁止 ブラウザが勝手に Content-Type を推測して script として実行する
Referrer-Policy Referer の送信制御 遷移先に内部 URL を漏らす
Permissions-Policy カメラ / マイク等の機能制限 第三者 iframe から勝手に機能利用
Content-Security-Policy スクリプト等の読み込み元を制限 XSS で外部スクリプトを実行される

next.config.ts で一括設定

import type { NextConfig } from "next";

const securityHeaders = [
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  // (self): 自サイトからは許可ダイアログを出せる、第三者 iframe からはブロック
  { key: "Permissions-Policy", value: "camera=(self), microphone=(self), geolocation=(self)" },
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // 本番では unsafe-* を削減
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self' https://api.example.com",
    ].join("; "),
  },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/:path*", headers: securityHeaders }];
  },
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "api.example.com", pathname: "/storage/**" },
    ],
  },
};

export default nextConfig;

注意点

  • images.remotePatterns でワイルドカード ** を多用しない。 Next.js Image はリモート画像を自サーバー経由でプロキシ最適化しますが、許可ドメインを広げすぎると SSRF (サーバーから任意 URL に fetch される) のリスクが上がります。必要なドメインだけ明示的に許可しましょう
  • CSP の unsafe-inline / unsafe-eval は本番では削減を目指す。 Next.js の dev では便利さのために必要になりがちですが、本番では nonce / hash 方式で締めると XSS 耐性が大きく上がります

9. エラーの境界 — 内部情報をユーザー画面に出さない

なぜ気をつける必要があるのか

開発中は便利なスタックトレースも、本番でユーザー(=攻撃者の可能性)に見せるのは情報漏洩です。「どのファイルの何行目で」「どの ORM が」「どのカラム名で」失敗したかは、攻撃者にとってはアプリ内部の地図そのものになります。

対策: 開発と本番で出し分け

export function getErrorMessage(error: unknown): string {
  if (process.env.NODE_ENV === "development") {
    if (error instanceof Error) return error.message;
    return String(error);
  }
  // 本番では汎用メッセージのみ
  return "エラーが発生しました。しばらくしてから再度お試しください。";
}

app/error.tsx でも本番では詳細を出さない

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // 本番では error.message を表示しない。digest は Sentry 等で内部参照する識別子
  return (
    <div>
      <h2>問題が発生しました</h2>
      <p>ページの読み込み中にエラーが発生しました。</p>
      <button onClick={() => reset()}>もう一度試す</button>
    </div>
  );
}

API エラーの種類は HTTP ステータスから振る

「どのエラーで何を表示するか」を統一すると、ユーザー向けのメッセージを安全に管理できます。

export function handleApiError(status: number): string {
  switch (status) {
    case 401: return "ログインが必要です";
    case 403: return "アクセス権限がありません";
    case 404: return "データが見つかりません";
    case 422: return "入力内容に誤りがあります";
    default:  return "エラーが発生しました";
  }
}

ポイント: バックエンドから返ってきたエラーメッセージをそのまま表示するのは避けます。バックエンドの実装によっては SQL エラーが文字列で混じっていたりするので、フロント側でも「想定内のステータスコード」にマッピングし直すのが安全です。


10. 依存パッケージの境界 — 自前コードより脆弱性が多い

なぜ気をつける必要があるのか

現代の Next.js アプリは、依存パッケージのコード量が自前コードの何十倍にもなります。脆弱性の数も依存側のほうが圧倒的に多い です。npm install した瞬間に、知らない作者が書いたコードが本番に乗っているという事実は意識しておく必要があります。

npm audit を PR 単位で回す

# 脆弱性の確認
npm audit

# 自動修正可能なものを修正
npm audit fix

# CI で High 以上は止める
npm audit --audit-level=high

対応の優先度

重大度 対応
Critical / High PR マージ前に対応必須。パッチが無ければ代替パッケージ検討
Moderate / Low 別タスク化して計画的に対応

Dependabot / Renovate を入れる

毎週手動で npm audit を回すのは続きません。GitHub なら Dependabot を有効化するだけで PR を自動生成してくれる ので、まずはこれを入れるのが現実的です。

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"

ポイント: 「全部自動マージ」にはしないこと。メジャーバージョンアップは破壊的変更が含まれるので、PR で人が見るプロセスは残します。


おまけ: ヘルスチェック API を1本だけ用意しておく

セキュリティそのものとは少しずれますが、運用と地続きなので触れておきます。本番で異変が起きた時に「アプリが生きているか」を機械的に確認できる窓口を1本作っておくと、後々助かります。

// app/api/health/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({
    status: "ok",
    timestamp: new Date().toISOString(),
  });
}

監視ツール、ロードバランサー、Kubernetes の readiness probe など、運用側からの監視窓口になります。バックエンド API の疎通確認まで含める「詳細ヘルスチェック」も後から拡張できます。


まとめ — 「境界」を意識すれば Next.js のセキュリティは整理できる

長くなったので、最初の表をもう一度貼ります。この10個の境界が一通り埋まっていれば、フロントエンドとしてはまず合格点 です。

# 境界 一言で言うと
1 環境変数 NEXT_PUBLIC_ は公開ファイルへの転写。Zod で起動時検証
2 Server → Client Props HTML に乗るので Zod でフィルタ
3 Server Action / Route Handler 認証・認可・バリデーション の3点セット
4 トークン保存 HttpOnly Cookie が原則、localStorage は避ける
5 XSS React の自動エスケープを壊さない、dangerouslySetInnerHTML 禁止
6 CSRF Server Action は自動保護、Route Handler は手動で Origin 検証
7 Open Redirect returnUrl はホワイトリストで検証
8 セキュリティヘッダー next.config.ts で多層防御
9 エラー表示 本番ではスタックトレース禁止、汎用メッセージのみ
10 依存パッケージ npm audit + Dependabot

本記事で扱っていないこと

冒頭で書いた通り、本記事は フロントエンドのコードで完結する対策 に絞りました。実運用ではこれに加えて、

  • WAF / CDN レイヤーでのレート制限・Bot 対策
  • TLS / 証明書管理
  • IP 制限・VPN 経由のアクセス制御
  • ログ集約・監視・SIEM
  • バックエンド (API サーバー) 側の SQL Injection 対策、ファイルアップロード検査
  • インシデント対応プロセス

といったインフラ・運用層の話が必要になります。これらは別記事で扱う予定です。

セキュリティは「全部完璧にしてからリリース」よりも、「境界ごとに何を守っているかを言語化しておく」 ことが事故時の被害を最小化します。本記事の表が、その言語化の出発点になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?