ロールベースアクセスのための Vue 3 ルーティングガード:実践的パターン
実践的なパターンで解説する Vue 3 のルーティングガードとロールベースアクセス:route meta のルール、安全なリダイレクト、親切な 401/403 フォールバック、データ漏洩の回避方法。

ルートガードが実際に解決するもの(およびできないこと)
ルートガードは一つの仕事をうまくこなします:ナビゲーションを制御すること。誰がルートに入れるか、入れない場合にどこに送るかを決めます。これにより UX は改善しますが、同時にそれがセキュリティそのものではありません。
メニュー項目を隠すことはヒントに過ぎず、認可ではありません。ユーザーは URL を直接入力したり、ディープリンクでリロードしたり、ブックマークを開いたりできます。「ボタンが見えない」だけでは保護されていません。
ガードが得意なのは、管理エリアや内部ツール、ロールベースのカスタマーポータルのように表示すべきでないページを一貫してブロックしたい場合です。
ガードが助けること:
- レンダリング前にページをブロックする
- ログインや安全な既定ページへリダイレクトする
- 壊れたビューではなく明確な 401/403 画面を表示する
- 意図しないナビゲーションループを回避する
ガードだけではデータを保護できないことに注意してください。API が機密データをブラウザに返してしまうと、ページがブロックされていてもユーザーはそのエンドポイントを直接叩くことができます(開発者ツールでレスポンスを調べることも含む)。本当の認可はサーバー側でも行う必要があります。
良い目標は両側をカバーすることです:ページをブロックし、データもブロックする。サポート担当が管理者専用ルートを開いたら、ガードはナビゲーションを止めて「Access denied」を表示すべきです。別途バックエンドは管理者専用 API 呼び出しを拒否して、制限されたデータが返らないようにしてください。
シンプルなロールと権限モデルを選ぶ
ロールのリストが長くなるとアクセス制御はややこしくなります。まずは人が実際に理解できる小さなセットで始め、実際に痛みを感じたときに細かい権限を追加してください。
実践的な分け方は:
- ロールはアプリ内で「誰であるか」を表す。
- 権限は「何ができるか」を表す。
多くの内部ツールでは三つのロールで十分な場合が多いです:
- admin:ユーザーと設定を管理し、すべてのデータを閲覧できる
- support:顧客レコードや対応を扱えるがシステム設定は不可
- viewer:承認された画面に対する読み取り専用アクセス
ロールの情報源を早めに決めてください。トークンのクレーム(JWT)はガードには高速ですが、リフレッシュされるまで古くなる可能性があります。アプリ起動時にユーザープロファイルを取得する方法は常に最新ですが、ガードはそのリクエストが完了するのを待つ必要があります。
また、ルートの種類を明確に分けてください:パブリック(誰でも開ける)、認証済み(セッションが必要)、制限付き(ロールや権限が必要)。
route meta でアクセスルールを定義する
アクセスを表現する最もきれいな方法はルート自体に宣言することです。Vue Router では各ルートレコードに meta オブジェクトを付けられるので、ガードはそれを後で読めます。ルールがページに近くまとまり、管理しやすくなります。
シンプルな meta の形を選び、アプリ全体で統一してください。
const routes = [
{
path: "/admin",
component: () => import("@/pages/AdminLayout.vue"),
meta: { requiresAuth: true, roles: ["admin"] },
children: [
{
path: "users",
component: () => import("@/pages/AdminUsers.vue"),
// inherits requiresAuth + roles from parent
},
{
path: "audit",
component: () => import("@/pages/AdminAudit.vue"),
meta: { permissions: ["audit:read"] },
},
],
},
{
path: "/tickets",
component: () => import("@/pages/Tickets.vue"),
meta: { requiresAuth: true, permissions: ["tickets:read"], readOnly: true },
},
]
ネストしたルートではルールをどのように合成するか決めてください。ほとんどのアプリでは子は親の要件を継承すべきです。ガードではマッチしたすべてのルートレコードを確認し(to.meta だけでなく)、親のルールがスキップされないようにしてください。
あとで時間を節約する小さな工夫:"表示できるか(can view)" と "編集できるか(can edit)" を区別しておくこと。あるルートは support と admin に表示されても、support では編集を無効にしたいかもしれません。meta に readOnly: true を入れて UI 側(アクションを無効化したり破壊的なボタンを隠す)を制御するとよいですが、それをセキュリティだと偽るべきではありません。
ガードが安定して動くように認証状態を用意する
多くのガードのバグは一つの問題から来ます:ガードが実行された時点でアプリがユーザーを知らないことです。
認証を小さな状態機械として扱い、単一の真実のソースにしてください。三つの明確な状態が欲しいです:
- unknown:アプリ起動直後でセッション未確認
- logged out:セッション確認が終わり、有効なユーザーがいない
- logged in:ユーザーが読み込まれ、ロール/権限が利用可能
ルール:認証が unknown の間は決してロールを読まないこと。これが保護された画面のフラッシュや驚きのログインへのリダイレクトの原因になります。
セッションリフレッシュの方法を決める
一つのリフレッシュ戦略を選び、予測可能にしてください(例:トークンを読み、"who am I" エンドポイントを呼び、ユーザーを設定する)。
安定したパターンの例:
- アプリ読み込み時に認証を unknown に設定し、単一のリフレッシュリクエストを開始する
- リフレッシュが終わる(またはタイムアウトする)までガードの判定を解決しない
- ユーザーはメモリ内にキャッシュし、ルートの meta にキャッシュしない
- 失敗したら認証を logged out に設定する
- ガードが待てるように
readyPromise(または類似)を公開する
これがあればガードロジックは単純になります:認証が ready になるのを待ち、アクセスを決定するだけです。
ステップバイステップ:ルートレベルの認可を実装する
きれいなアプローチは、ほとんどのルールを一つのグローバルガードにまとめ、ルート固有のロジックが本当に必要な場合だけ個別のガードを使うことです。
1) グローバルな beforeEach ガードを追加
// router/index.js
router.beforeEach(async (to) => {
const auth = useAuthStore()
// Step 2: wait for auth initialization when needed
if (!auth.ready) await auth.init()
// Step 3: check authentication, then roles/permissions
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
const roles = to.meta.roles
if (roles && roles.length > 0 && !roles.includes(auth.userRole)) {
return { name: 'forbidden' } // 403
}
// Step 4: allow navigation
return true
})
これで大半のケースはカバーでき、チェックをコンポーネント側に散らす必要が減ります。
beforeEnter が適している時
beforeEnter は本当にルート固有のルール(例:「このチケットは所有者しか開けない」など)で、to.params.id に依存する場合に使ってください。短く保ち、同じ認証ストアを使って振る舞いを一貫させましょう。
穴を開けない安全なリダイレクト
リダイレクトは信頼しすぎるとアクセス制御を静かに無効にします。
一般的なパターンは:未認証のユーザーはログインへ送り、returnTo クエリに現在のパスを入れる。ログイン後にそれを読み取って遷移する。リスクはオープンリダイレクト(意図しない先へ飛ばす)やループです。
動作をシンプルに保ってください:
- 未認証ユーザーは現在のパスを
returnToとしてLoginに送る。 - 認証済みだが権限がないユーザーは専用の
Forbiddenページへ送る(Loginではない)。 returnToの値は内部の許可されたものだけにする。- 同じ場所へ戻るループが起きないように一つのチェックを入れる。
const allowedReturnTo = (to) => {
if (!to || typeof to !== 'string') return null
if (!to.startsWith('/')) return null
// optional: only allow known prefixes
if (!['/app', '/admin', '/tickets'].some(p => to.startsWith(p))) return null
return to
}
router.beforeEach((to) => {
if (!auth.isReady) return false
if (!auth.isLoggedIn && to.name !== 'Login') {
return { name: 'Login', query: { returnTo: to.fullPath } }
}
if (auth.isLoggedIn && !canAccess(to, auth.user) && to.name !== 'Forbidden') {
return { name: 'Forbidden' }
}
})
ナビゲーション中に制限付きデータを漏らさない
最も簡単な漏洩は、ユーザーが見てよいか分かる前にデータを読み込んでしまうことです。
Vue ではページが setup() でデータを取得し、その後にガードが実行されるとよく起きます。ユーザーがリダイレクトされてもレスポンスが共有ストアに入り込んだり一瞬表示されたりする可能性があります。
より安全なルールは:先に認可を行い、その後データを読み込むことです。
// router guard: authorize before entering the route
router.beforeEach(async (to) => {
await auth.ready() // ensure roles are known
const required = to.meta.requiredRole
if (required && !auth.hasRole(required)) {
return { name: 'forbidden' }
}
})
急速なナビゲーション変更時に発生する遅延リクエストにも注意してください。リクエストをキャンセルする(AbortController など)か、リクエスト ID をチェックして遅いレスポンスを無視するようにしてください。
キャッシュもよくある罠です。もし「最後に読み込んだ顧客レコード」をグローバルに保持していると、管理者専用のレスポンスが後で権限のないユーザーに表示される可能性があります。キャッシュはユーザー ID やロールでキーを付ける、またはログアウト時(あるいはロール変更時)に機密モジュールをクリアしてください。
いくつかの習慣で大半の漏洩を防げます:
- 認可が確認されるまで機密データを取得しないこと。
- キャッシュはユーザーとロールでスコープするか、ページローカルに保つこと。
- ルート変更時は進行中のリクエストをキャンセルまたは無視すること。
親切なフォールバック:401、403、404
「ノー」の経路は「イエス」の経路と同じくらい重要です。良いフォールバックページはユーザーの迷子を防ぎ、サポート問い合わせを減らします。
401:ログインが必要(未認証)
ユーザーがサインインしていない場合に 401 を使います。メッセージは短く:続行するにはログインが必要です。ログイン後に元のページへ戻す場合は、戻り先の検証をしてアプリ外に飛ばないようにしてください。
403:アクセス拒否(認証済みだが許可なし)
ユーザーがサインインしているが権限がない場合に 403 を使います。中立的な表現にして、機密な詳細を示唆しないでください。
良い 403 ページは通常、明確なタイトル(「Access denied」など)、一文の説明、そして安全な次のステップ(ダッシュボードへ戻る、管理者に問い合わせる、アカウント切替機能があればそれを提案)を提供します。
404:見つかりません
404 は 401/403 とは別に扱ってください。さもないとユーザーはページが単に存在しないだけなのに権限がないと誤解します。
アクセス制御を壊す一般的なミス
多くのバグは単純な論理ミスから来て、リダイレクトループ、誤ったページのフラッシュ、ユーザーがスタックする、といった形で現れます。
よくある原因:
- 隠された UI を「セキュリティ」とみなす。ルーターと API の両方でロールを強制すること。
- ログアウト/ログイン後に古い状態からロールを読んでしまう。
- 権限のないユーザーを別の保護されたルートへリダイレクトしてしまう(瞬間ループ)。
- リフレッシュ時の「認証がまだ読み込み中」を無視する。
- 401 と 403 を混同してユーザーを混乱させる。
現実的な例:サポート担当がログアウトし、その後同じ共有 PC で管理者がログインした場合、ガードが新セッションを確認する前にキャッシュされたロールを読んでしまうと、管理者を誤ってブロックしたり、逆に一瞬アクセスを許してしまう可能性があります。
出荷前のクイックチェックリスト
ネットワークが遅い場合、セッション切れ、ブックマークされた URL など、アクセス制御が壊れやすい瞬間に焦点を当てた短い確認を行ってください:
- すべての保護されたルートに明示的な
meta要件がある。 - ガードは認証の読み込み状態を扱い、保護された UI をフラッシュさせない。
- 権限のないユーザーは混乱するホームへの戻しではなく明確な 403 に到達する。
- すべての "return to" リダイレクトは検証され、ループを作らない。
- 機密な API 呼び出しは認可が確認されてから実行される。
その後、エンドツーエンドのシナリオを一つテストしてください:サインアウト状態で保護された URL を新しいタブで開き、基本ユーザーとしてサインインして、許可されていれば意図したページに着くか、許可されていなければクリーンな 403 が表示され次の手順が示されるかを確認します。
例:小さな Web アプリでの support と admin のアクセス
ヘルプデスクアプリを想像してください。ロールは support と admin。support はチケットの閲覧と返信ができ、admin はそれに加えて課金や会社設定を管理できます。
/tickets/:idはsupportとadminに許可/settings/billingはadminのみ許可
一般的な場面:support が古いブックマークから /settings/billing のディープリンクを開くことがあります。ガードはページが読み込まれる前に route meta をチェックしてナビゲーションをブロックするべきです。ユーザーはログイン済みだがロールが不足しているので、安全なフォールバック(403)へ誘導されます。
重要なメッセージは二つです:
- Login required (401): 「続行するにはサインインしてください。」
- Access denied (403): 「課金設定にアクセスする権限がありません。」
起こってはならないこと:billing コンポーネントがマウントされたり、billing データが一瞬でも取得されること。
セッション中にロールが変わる場合のエッジケースもあります。誰かが昇格・降格されたとき、メニューに頼らないでください。ナビゲーション時にロールを再チェックし、プロファイルが変わったら認証状態を更新するか、ロール変更を検出して許可されなくなったページからリダイレクトする設計にしてください。
次のステップ:アクセスルールを保守しやすくする
ガードが動いたら、より大きなリスクはルールのズレです:新しいルートが meta なしで出荷される、ロール名が変わる、ルールが一貫しなくなる。
ルートを追加するたびに回せる小さなテストプランを作ってください:
- ゲストで:保護ルートを開き、部分的なコンテンツが見えないでログイン画面に行くことを確認する。
- ユーザーで:アクセスできないページを開き、明確な 403 が出ることを確認する。
- 管理者で:アドレスバーからコピーしたディープリンクを試す。
- 各ロールで:保護されたルートでリフレッシュし、結果が安定していることを確認する。
余分な安全網として、開発時のみルートとその meta 要件を一覧表示するビューやコンソール出力を追加すると、欠けているルールがすぐに目に付くようになります。
内部ツールやポータルを AppMaster (appmaster.io) で構築しているなら、同じアプローチが当てはまります:Vue3 UI 側のルートガードはナビゲーションに集中させ、権限やデータの保護はバックエンドロジック側で行ってください。
一つ改善を選んでエンドツーエンドで実装してください:データ取得のゲーティングを強化する、403 ページを改善する、リダイレクト処理を厳密にする――小さな修正が現実のバグの多くを止めます。
よくある質問
ルートガードはナビゲーションを制御します。ページをブロックしたりリダイレクトしたり、きれいな 401/403 を表示するには有効ですが、API を直接呼ばれるのを防ぐことはできません。制限されたデータが決して返らないように、必ずバックエンド側でも同じ権限チェックを行ってください。
UI を隠すだけでは「見え方」を変えるだけで、ユーザーがリクエストできる能力は変わりません。ユーザーは URL を直接入力したり、ブックマークやディープリンクを開くことができます。ページをブロックするにはルーター側のチェック、データをブロックするにはサーバー側の認可が必要です。
人が理解しやすい小さなセットから始め、問題が出たら権限を追加します。よくある基準は admin、support、viewer の三つで、必要に応じて tickets:read や audit:read のような権限を追加します。"誰であるか"(ロール)と"何ができるか"(権限)は分けて考えてください。
ルートレコードの meta にアクセスルールを置きます。requiresAuth、roles、permissions のようにすると、保護ルールがページに近く分かりやすくなり、グローバルガードで予測可能に扱えます。ネストしたルートでは、親の要件が子でスキップされないようにすべてのマッチしたレコードをチェックしてください。
to.matched を読み、マッチしたすべてのルートレコードの要件を組み合わせてください。こうすることで子ルートが親の requiresAuth や roles を回避できなくなります。あらかじめ結合ルールを決めておくと良いです(通常は親の要件を子に適用)。
ガードがアプリ起動直後に実行され、ユーザー情報が未取得のまま判定すると発生します。認証を unknown、logged out、logged in の三状態で扱い、認証が unknown の間はロールを評価しないでください。ガードは初期化(例: who am I リクエスト)が終わるのを待つようにします。
一貫したルール(ログイン必須やロール/権限必須)にはグローバルな beforeEach を使い、params に依存する「チケットの所有者だけ」など本当にルート固有のルールには beforeEnter を使ってください。どちらの場合も同じ認証ソースを使って一貫性を保ちます。
returnTo は信頼できない入力として扱ってください。/ で始まる内部パスのみ許可し、既知のプレフィックスに合致するかを確認します。ループ防止のチェックも入れ、未認証は Login、認証済みだが権限無しは 403 ページへ送るようにします。
認可を確認する前にデータを取得すると漏洩が起きます。ページが setup() でデータを取りに行き、ガードで遷移が止まってもレスポンスがストアに残ったり一瞬表示されたりします。機密リクエストは認可確認後に行い、ナビゲーション変更時は AbortController でキャンセルするか、レスポンスが古い場合は無視してください。
401 は未認証(ログインが必要)、403 は認証済みだが権限がない場合に使います。404 は別扱いにして、ページが単に存在しないのか権限の問題なのかをユーザーが判断できるようにしてください。明確なフォールバックは混乱とサポートを減らします。


