「ログインしていないユーザーが /member/ 配下にアクセスしたら /login に飛ばしたい」
この要件は Next.js の middleware / proxy レイヤーで実現します。ページコンポーネント側に認証チェックを書いても動きますが、複数ページに同じコードが散らばり、抜け漏れが起きやすくなります。認証ガードはリクエストの入口で一元管理するのがベストプラクティスです。
この記事では THINK YOU LAB 本番環境で稼働している middleware のコード(proxy.ts)を丸ごと解説します。
middleware / proxy とは何か
App Router における middleware の役割
Next.js の middleware は、リクエストがサーバーに届いてからページコンポーネントが実行されるより前に動く処理です。すべてのリクエストに対してフィルタリング・リダイレクト・レスポンス加工を行えます。
認証ガードの文脈では「Cookie を見てユーザーが認証済みかどうかを判定し、未認証なら /login に転送する」役割を担います。
proxy.ts の配置場所(THINK YOU LAB の現行実装)
THINK YOU LAB の現行実装では、認証ガードはプロジェクトルートの proxy.ts に置いています。
lms/
app/
proxy.ts ← 認証ガード本体
この proxy.ts に proxy 関数と config.matcher を定義することで、どのパスに認証ガードを適用するかを制御できます。
matcher で「どのリクエストに適用するか」を制御する
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)",
],
};この正規表現は「以下のパスを除くすべてのリクエストに middleware を適用する」という意味です。
| 除外パス | 理由 |
|----------|------|
| _next/static | 静的ファイル(JS・CSS)。認証不要 |
| _next/image | Next.js の画像最適化エンドポイント。認証不要 |
| favicon.ico | ファビコン。認証不要 |
| api/webhooks | Stripe Webhook など。自前の署名検証で保護(後述) |
Supabase 認証を middleware で扱う際の注意点
getSession() ではなく getUser() を使う理由
Supabase の認証情報を取得するメソッドは2つあります。
| メソッド | 動作 | セキュリティ |
|----------|------|------------|
| getSession() | Cookie のセッション情報をそのまま返す | Cookie が改ざんされていても検知できない |
| getUser() | Supabase サーバーにトークンの有効性を問い合わせる | サーバー側で検証するため、改ざんを検知できる |
middleware での認証判定は必ず getUser() を使うことが Supabase 公式の推奨です。
Cookie の更新を middleware で行う理由
Supabase のアクセストークンは有効期限(デフォルト1時間)があります。getUser() を呼ぶ際にリフレッシュ処理が自動的に実行されます。middleware がすべてのリクエストに対して実行されるため、トークンが切れたタイミングでも自然にリフレッシュされ、ユーザーはログイン状態を維持できます。
実装コード(THINK YOU LAB 実例)
proxy.ts の全コード解説
// lms/proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const MEMBER_PREFIX = "/member";
const ADMIN_PREFIX = "/admin";
export async function proxy(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
const { pathname } = request.nextUrl;
if (
!user &&
(pathname.startsWith(MEMBER_PREFIX) || pathname.startsWith(ADMIN_PREFIX))
) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
if (user && pathname.startsWith(ADMIN_PREFIX)) {
const { data: profile } = await supabase
.from("users")
.select("role")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") {
const url = request.nextUrl.clone();
url.pathname = "/member/courses";
return NextResponse.redirect(url);
}
}
return supabaseResponse;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)",
],
};認証ガードのロジック
コアのガードロジックはシンプルです。
if (
!user &&
(pathname.startsWith(MEMBER_PREFIX) || pathname.startsWith(ADMIN_PREFIX))
) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}条件は2つ:
userがnull(未認証)- アクセス先が
/memberまたは/adminで始まる
/login や公開ページはこれらのプレフィックスで始まらないため、未認証でも通過します。現行実装では /admin に対してロール判定も入っており、ログイン済みでも role != 'admin' のユーザーは /member/courses へ戻します。
next クエリパラメータを付けてリダイレクトする
loginUrl.searchParams.set("next", pathname);ログインページが元のアクセス先パスを保持します。ログイン成功後に /auth/callback が next パラメータを受け取って元のページにリダイレクトするため、「ログインしてから元いたページに戻る」という自然な UX が実現します。
Cookie の双方向設定(setAll の中身)
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value) // (1) リクエストを更新
);
supabaseResponse = NextResponse.next({ request }); // (2) 新しいレスポンスを作り直す
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options) // (3) レスポンスに Cookie を設定
);
},この3ステップを省略すると Cookie の同期が壊れ、ページ遷移のたびにセッションが失われます。
/api/webhooks を matcher から除外する理由
Stripe が送る Webhook リクエストには Supabase の Cookie が存在しません。/api/webhooks/stripe は Stripe の Webhook 署名(STRIPE_WEBHOOK_SECRET)で保護するため、middleware の対象から意図的に外しています。
Stripe Webhook 実装の詳細 → Supabase + Stripe Webhook で会員ステータスを自動更新する
DB レベルのアクセス制御は RLS で → Supabase RLS の書き方入門
よくある詰まりポイント
middleware が動かない(matcher の正規表現ミス)
症状: 保護したはずのページが未認証でアクセスできる
よくある間違い:
// 間違い: / で始まらないパスを書いてしまっている
matcher: ["member/:path*"]
// 正しい
matcher: ["/member/:path*"]また、認証ガードのファイル位置が正しいか確認します。THINK YOU LAB の現行実装では proxy.ts をプロジェクトルート直下(lms/proxy.ts)に置いています。
リダイレクトループが起きる
症状: ログインページにアクセスするたびにリダイレクトが繰り返される
原因: /login ページ自体が MEMBER_PREFIX や ADMIN_PREFIX に含まれていないか確認します。また getUser() が常に null を返す(セッション Cookie が壊れている)ケースも原因になります。
Cookie が引き継がれない
症状: ログイン後に別ページに遷移すると、また未認証状態になる
原因: setAll の実装が不完全。特に supabaseResponse = NextResponse.next({ request }) のリセットが抜けていると、新しい Cookie がレスポンスに含まれません。
ブラウザの開発者ツールで「Application」→「Cookies」を見て、sb-xxx-auth-token のような Cookie が設定されているか確認します。
まとめ
Next.js App Router の middleware を使った認証ガードのポイント:
-
getUser()を使う(getSession()は Cookie 改ざんを検知できないため不可) -
setAllは request・response の両方に Cookie を設定する(3ステップ) -
/loginなどの公開ページは保護対象から外す(MEMBER_PREFIX/ADMIN_PREFIXで管理) -
nextパラメータでログイン後の復帰先を保持する -
/api/webhooksは matcher から除外し、Stripe 署名検証に委ねる -
Supabase 認証(magic link)の実装はこちら → Supabase magic link でログイン機能を実装する
-
DB レベルのアクセス制御は RLS で → Supabase RLS の書き方入門
middleware で認証ガードを作ると、ログインしていないユーザーを弾く処理が1ファイルで管理できます。Next.js 認証 middleware テンプレを LINE で無料配布中です。
→ Next.js 認証 middleware テンプレを受け取る(LINE登録・無料)
LINE 公式アカウント
Next.js App Router 実装Tips・Supabase Auth 設定ガイドをLINEで配信中