NextJSで直リンクを防ぐ

Tech

目的・セキュリティレベル別に選ぶ直リンク防止策

NextJSでの直リンク防止について調べていると、意外にも体系的にまとめられている記事がなかったのでまとめてみます。

目的・セキュリティレベルを軸に整理し、実際のユースケースに合わせて選択できるよう解説します。

はじめに:「防止」の目的を整理する

「直リンク防止」といってもその目的は様々です。
実装方法を選ぶ前に、何を防ぎたいのかを明確にしておきます。

例:

  • フォームの確認画面に直接アクセスされたくない、
  • 管理画面を認証なしで開かれたくない
  • APIを外部から叩かれたくない

そしてその目的がUXなのかセキュリティなのかによって、適切な手段も変わってきます。

UX目的
フォームを経由せず確認画面や完了画面に飛ばれると、処理が成立しない。直接アクセスは「想定外の操作」であり、適切なページに戻してあげれば十分。

セキュリティ目的
認証されていないユーザーが管理画面や機密コンテンツにアクセスできてしまう。これはUX改善ではなくセキュリティ上の問題であり、より堅牢な対策が必要。

この2つを混同すると、過剰な実装や逆に不十分な対策につながります。

方法1:そもそもURLを持たせない(セキュリティ:-)

直接アクセスされたくないページは、そもそもURLを持たせる必要があるか検討しましょう。

例えばフォームの確認画面や完了画面に直リンクできないようにしてステップを順に踏ませたい、という場合、独立したURLを与えず、コンポーネントの状態として管理することで直リンクを物理的に不可能にできます。

export default function FormPage() {
  const [step, setStep] = useState<'input' | 'confirm' | 'done'>('input');

  return (
    <>
      {step === 'input' && <InputStep onNext={() => setStep('confirm')} />}
      {step === 'confirm' && (
        <ConfirmStep
          onBack={() => setStep('input')}
          onSubmit={() => setStep('done')}
        />
      )}
      {step === 'done' && <DoneStep />}
    </>
  );
}

セキュリティ上の保護はありませんが、UX設計としては最もシンプルです。
これならブラウザの履歴にも残らないため、「戻るボタンで確認画面に戻れてしまう」という問題も発生しません。

注意点

ページをリロードすると最初のステップに戻るため、入力内容を保持したい場合はセッションストレージやグローバルストアの永続化などの対策が別途必要です。

方法2:Refererヘッダーによるチェック(セキュリティ:低)

「特定のページから来た場合だけ表示する」という簡易なリダイレクト処理に使えます。

import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default function ConfirmPage() {
  const referer = headers().get('referer') ?? '';

  if (!referer.includes('/form')) {
    redirect('/form');
  }

  return <ConfirmContent />;
}

注意点

Refererヘッダーはブラウザのプライバシー設定(Referrer-Policy: no-referrer)によって送信されないことがあります。
また、curlなどのツールでは任意の値を設定できるため、意図的に偽装することも難しくありません。

「直接入力で来たユーザーを正しい導線に戻す」程度の用途には十分ですが、セキュリティ目的では使えません。

方法3:Middlewareによる保護(セキュリティ:高)

認証・認可が必要なページ全体を保護する場合の一次防衛として最適です。
リクエストがページに到達する前にサーバー側で処理されるため、HTMLが返却される前にアクセスを遮断できます。

実装方法はプロジェクトルートに以下のようなmiddleware.tsを配置するだけです。
(Next.jsのv16からはproxy.tsになっているので注意[1]

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session_token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  // 保護したいパスだけに絞る
  matcher: ['/dashboard/:path*', '/admin/:path*'],
};

matcherを使うことで特定のパスだけに適用できます。
全体に適用したい場合は静的ファイルや認証不要のパスを除外する書き方にします。

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|login).*)'],
};

注意点

MiddlewareはEdge Runtimeで動作するため、Node.js固有のモジュール(fscrypto 等)は使用できません。

トークンの簡易な存在チェックや署名検証(JWTなど)には対応していますが、データベースへのアクセスが必要な複雑な認可ロジックはここには書けません。
その場合はAPIルートかServer Actionに委ねます。

方法4:AuthGuardによる保護(セキュリティ:中)

Middlewareがリクエスト単位のサーバーサイドの遮断であるのに対し、AuthGuardはクライアントサイドで認証状態を監視し、ページコンポーネントのレンダリングを制御します。
Middlewareを通過した後の二次防衛として機能します。

App Routerではlayout.tsxに組み込むことで、配下のページ全体に適用できます。

// components/AuthGuard.tsx
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);

  useEffect(() => {
    if (!isAuthenticated) {
      router.replace('/login');
    }
  }, [isAuthenticated, router]);

  if (!isAuthenticated) return null;

  return <>{children}</>;
}
// app/dashboard/layout.tsx
import { AuthGuard } from '@/components/AuthGuard';

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return <AuthGuard>{children}</AuthGuard>;
}

注意点

クライアントサイドで動作するため、JavaScriptが実行されるまでの一瞬にコンテンツが表示されるフラッシュが発生することがあります。[2]
また、ページリロード時に状態管理ストアがリセットされる場合は、クッキーやサーバーセッションと照合して認証状態を復元する処理が別途必要です。

セキュリティ要件が高い場合は、Middlewareと組み合わせて使うのが望ましいです。

方法5:Route Handlerの保護(セキュリティ:高)

app/api/以下のAPIエンドポイント[3]は、ブラウザから直接GETでアクセスしたり、外部スクリプトからPOSTを送ることができます。
機密処理を行うエンドポイントにはセッション検証やJWT検証などの保護が必要です。

// app/api/submit/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySession } from '@/lib/auth';

export async function POST(req: NextRequest) {
  // セッション検証
  const session = await verifySession(req);
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Originチェック
  const origin = req.headers.get('origin') ?? '';
  if (origin !== process.env.BASE_URL) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  // 処理...
  return NextResponse.json({ success: true });
}

また、外部から叩かれたくないエンドポイントはGETではなくPOSTにするだけでも、URL入力による単純なアクセスは防ぐことができます。

注意点

Originヘッダーも技術的には偽装できるため、これ単独では不十分です。
セッションまたはJWTによる認証情報の検証が必須です。

まとめ

やりたいこと 推奨する方法 セキュリティ
フォームのステップを順番に踏ませたい URLを持たせない設計 -
想定外の導線からのアクセスをリダイレクトしたい Refererチェック(UX目的のみ)
未認証ユーザーを保護ページから締め出したい Middleware(必須)+ AuthGuard(推奨) 中~高
APIを外部や直接アクセスから保護したい Route Handlerの保護

セキュリティが求められる場面では、Middlewareを軸に複数の手段を組み合わせる多層防御が基本になります。

脚注
  1. https://nextjs.org/docs/app/getting-started/proxy ↩︎

  2. https://github.com/vercel/next.js/discussions/45746 ↩︎

  3. https://nextjs.org/docs/app/getting-started/route-handlers ↩︎