実践: 認証とデプロイ — ログイン処理とビルド成果物の配置
連載の最後は、ここまでの部品を認証という 1 つの機能へまとめます。扱いを間違えると被害が大きい部分なので、動く形より先に「どこに置くか」を決めます。
そのあとビルド成果物をホスティングへ載せるところまで進みます。
この章で学ぶこと
- トークンを
localStorageに置かない理由と、代わりに何を使うか httpOnlycookie をサーバーで発行する形と、useCookieから見たときの違いdefineNuxtRouteMiddlewareとnavigateToで遷移を止める書き方。returnを忘れると何が起きるかnuxt buildとnuxt generateが何を出すか。200.htmlと404.htmlの役割- 静的ホスティングの fallback 設定が provider ごとに違うこと
トークンをどこに置くか
多くの記事が localStorage.setItem('token', ...) と書きますが、OWASP はセッション識別子をここに置くことを明示的に禁じています。ログイン状態を表すトークンはこれに当たります。
Do not store session identifiers in local storage as the data is always accessible by JavaScript.
推奨されているのは cookie です。
Cookies can mitigate this risk using the
httpOnlyflag.
httpOnly が付いた cookie は document.cookie に現れないので、XSS でスクリプトを差し込まれても値を読み出して外部へ送れません。localStorage は JavaScript から常に見えるので、この防御が効きません。
守れるのは「値が漏れること」までです。 注入されたスクリプトが同じサイトへ fetch('/api/...', { method: 'POST' }) を撃てば、ブラウザが cookie を自動で付けるので操作自体は成立します。cookie にすると CSRF の面が新しく開くので、sameSite の指定が要ります (後述)。
Nuxt では理由がもう 1 つあります。9 章と 18 章で見たとおり、サーバー側に localStorage はありません。トークンをそこに置くと、サーバーは誰がログインしているか分からないまま HTML を描くことになります。初回の表示が必ず未ログイン状態になり、ログイン済みの画面はクライアントで JavaScript が動いてから差し替わります。
cookie ならサーバーがリクエストヘッダで受け取れるので、最初の HTML からログイン済みの状態で描けます。
ただし 18 章で見たとおり、サーバー側の描画中に $fetch で API を叩くと cookie は転送されません。認証が要る API をサーバー側から呼ぶなら、useRequestFetch() を通すか、useFetch に相対 URL を渡してください。
トークンをサーバーで発行する
httpOnly cookie はサーバー側でセットします。ログインのエンドポイントはこうなります。
以下のコードはトークンを固定文字列で発行し、検証もしていません。cookie をどこに置き、どう運ぶかを見せるための最小形です。
httpOnly が塞ぐのは読み取りだけで、書き込みは塞ぎません。 利用者は devtools でも curl でも好きな値を cookie に置けるので、サーバー側で毎回トークンを検証しなければ保護になりません。実際には署名付きトークン (JWT なら署名の検証) か、サーバー側に持ったセッションの照合が要ります。この章はその部分を扱いません。
export default defineEventHandler(async (event) => {
const { email } = await readBody<{ email?: string }>(event)
if (!email) {
throw createError({ statusCode: 400, message: 'email が要ります' })
}
// トークンは httpOnly cookie で返す。本文には入れない
setCookie(event, 'token', 'issued-by-server', {
httpOnly: true,
secure: !import.meta.dev,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return { email }
})
本番ビルドの応答ヘッダは token=issued-by-server; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax になります (開発ビルドでは Secure が付きません)。トークンの値をレスポンスの本文に入れないでください。 本文に入れると呼び出し側の JavaScript が受け取れてしまい、httpOnly にした意味がなくなります。
sameSite: 'lax' は別サイトからの POST に cookie を付けさせない指定です。cookie は同じサイトへのリクエストに自動で付くので、これを付けないと外部サイトのフォームから操作されます (CSRF)。
secure を import.meta.dev で切り替えているのは、secure を付けた cookie は HTTPS でないと送り返されないためです。http://localhost は多くのブラウザが例外扱いにしますが、LAN の IP や独自のホスト名で開発サーバーを開くと送られなくなります。
ログアウトは削除するだけです。
export default defineEventHandler((event) => {
deleteCookie(event, 'token', { path: '/' })
return { ok: true }
})
deleteCookie は token=; Max-Age=0; Path=/ を返します。path と domain はセットしたときと同じ値を渡してください。 cookie はこの 2 つも含めて識別されるので、違うと別のものとみなされて消えません。
useCookie から見える範囲
useCookie は cookie を ref として読み書きします。ただし httpOnly の cookie に対しては、サーバー側とクライアント側で見え方が違います。
サーバー側の描画中はリクエストヘッダの Cookie を読むので、httpOnly でも値が取れます。クライアント側は document.cookie を読むので、httpOnly の cookie は見えません。Nuxt の doc も「compliant clients will not allow client-side JavaScript to see the cookie in document.cookie」と注意しています。
この非対称性を無視すると壊れます。 useCookie('token').value をそのまま判定に使うと、サーバー側では通るのに、hydration 後のクライアント遷移では undefined になってログイン済みのユーザーが弾かれます。
ログイン済みかどうかは useState に載せて渡します。 useState の値は payload に入ってクライアントへ引き継がれるので、両側で同じ判定になります。
// .server の suffix でサーバー側だけで走る
export default defineNuxtPlugin(() => {
const token = useCookie('token')
// useState の値は payload でクライアントへ渡る
useState('loggedIn', () => Boolean(token.value))
})
payload はクライアントへ渡るので、ここに載せてよいのは漏れて困らない情報だけです。トークンの値そのものを useState に入れないでください。 httpOnly にした意味がなくなります。
この plugin は .server の suffix が付いているので、サーバー側の描画のときにしか走りません。つまり ログインした直後には走らないので、その場で状態を更新する必要があります。
import { hasProtocol } from 'ufo'
/**
* ログイン後の戻り先として安全な値だけを通す。
* 判定は navigateTo が内部で使う hasProtocol に委ねる。
*/
export function safeRedirect(value: unknown) {
return typeof value === 'string' &&
value.startsWith('/') &&
!hasProtocol(value, { acceptRelative: true })
? value
: '/'
}
app/utils/ に置いたものは自動で import されるので、ページ側では呼ぶだけです。
<script setup lang="ts">
const route = useRoute()
const loggedIn = useState('loggedIn', () => false)
const email = ref('')
async function submit() {
await $fetch('/api/login', { method: 'POST', body: { email: email.value } })
// サーバー専用 plugin は次の全体読み込みまで走らないので、ここで状態を更新する
loggedIn.value = true
// クエリの値はそのまま navigateTo へ渡さない。外部 URL は投げられる
await navigateTo(safeRedirect(route.query.redirect))
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="email" type="email" name="email" />
<button type="submit">ログイン</button>
</form>
</template>
redirect クエリをそのまま navigateTo へ渡さないでください。 外部 URL が入っていると external: true が無いので投げられ、submit() の中で捕まえていなければログインは成立したのに画面が動かない状態になります。/ で始まる相対パスだけを通す関門を挟みます。自分で文字列を見比べないでください — //evil.example.com や /\evil.example.com はもちろん、?redirect=/%20/evil.example.com のようにクエリが復号されて / /evil.example.com になる形も別ホストへ向かいます。navigateTo が内部で使っている hasProtocol にそのまま判定させるのが確実です。
loggedIn.value = true を忘れると、ログインは成功しているのに /secret へ行けません。 cookie はセットされているので、ページを丸ごと読み込み直せば通ります — 「一度リロードすれば直る」という分かりにくい症状になります。
ログアウトも同じで、false に戻さないとcookie を消したのに画面はログイン済みのままになります。
遷移を保護する
ページごとの保護は app/middleware/ に置いたミドルウェアで行います。
export default defineNuxtRouteMiddleware((to) => {
// httpOnly cookie はクライアントから読めないので、状態は useState で共有する
const loggedIn = useState('loggedIn', () => false)
// navigateTo は必ず return する。返さないとガードが黙って素通りする
if (to.meta.requiresAuth && !loggedIn.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})
適用するページ側は 19 章の definePageMeta で指定します。requiresAuth は決まったキーではなく、自分で足したカスタムキーです。
<script setup lang="ts">
definePageMeta({ middleware: 'auth', requiresAuth: true })
</script>
<template>
<p class="secret">秘密</p>
</template>
cookie を持たずに /secret を開くと、サーバーが HTTP 302 で /login?redirect=/secret を返します。クライアントで JavaScript が動く前に判定が終わるので、保護されたページの中身が一瞬見えることがありません。
これは nuxt build でサーバーを動かす構成の話です。 後述する nuxt generate で静的サイトにすると、リクエストを受けるサーバーがいないので 302 は起きません。サーバー専用の plugin が走るのはビルドのときだけなので、payload に焼き込まれるのもそのときの値です。
この章の形は、リクエストのたびにサーバーが動く構成が前提です。 判定材料を用意する .server plugin が動くのはサーバー側の描画のときだけなので、事前に HTML を作って配るだけの構成では最初の値が入りません。nuxt generate はもちろん、ssr: false を付けた nuxt build も同じです。認証が要るページを持つなら、リクエストを受けるサーバーが動く形 (既定の nuxt build) を選んでください。
navigateTo は return する
公式は「Make sure to always use await or return on result of navigateTo when calling it」と書いています。ミドルウェアの中では return が必須です。16 章の Vue Router のガードと同じで、何も返さなければ通るのが既定の挙動だからです。
返さないとガードは黙って素通りします。 ミドルウェアの中の navigateTo は遷移先を表すオブジェクトを返すだけで、それ自体では遷移を起こしません。サーバー側で実測すると、return を落としたページは 302 ではなく 200 で描かれます。クライアント側も実装は同じ形なので、遷移は起きません。認証のガードでこれをやると保護が丸ごと外れるので、return の付け忘れは目視で確認してください。
<script setup> の中で呼ぶ場合も注意が要ります。navigateTo を呼んでも、その後ろのコードは止まりません。 続きを走らせたくないなら、自分で return してください。
外部の URL へ飛ばすときは external: true が要ります。付けないと投げられます。 この場合はミドルウェアの中でも実際に遷移が起きるので、上の「返すだけ」の話は当てはまりません。
await navigateTo('https://example.com', { external: true })
ビルドすると何が出るか
nuxt build は .output/ に成果物を出します。中身は preset で変わります。 既定の node-server preset なら、Node サーバーとして動く形になります。
.output/
├── nitro.json # preset や起動コマンドのメタ情報
├── public/ # そのまま配信する静的ファイル
└── server/
└── index.mjs # 起動するエントリポイント
起動は node .output/server/index.mjs です。.output/ だけをコピーすれば動きます — 必要な依存は .output/server/node_modules/ に取り込まれているので、プロジェクトの node_modules は要りません。preset を変えるとエントリポイントも構成も変わるので、nitro.json の commands.preview を見ると確実です。
nuxt generate は静的サイトを .output/public/ に出します。ルートごとにディレクトリと index.html ができ、加えてフォールバック用の 2 つの HTML が生成されます。
| ファイル | 役割 |
|---|---|
200.html | どのルートにも一致しなかったときに返す SPA fallback。クライアント側のルーティングに処理を任せる |
404.html | 404 のまま返したいときのフォールバック |
どちらもクライアント側で中身を描く前提のページです。エラー表示は error.vue がクライアントで動いてから出ます。
ホスティングへ載せる
静的ホスティングでは、一致しなかった URL をどのファイルへ向けるかを設定します。ここが provider ごとに違います。公式もそう書いています。
Some providers use
200.html, some use404.html, and some let you configure both. Check your hosting provider's static fallback or rewrite settings after deployment.
生成した .output/public/ を手で Netlify へ上げるなら、public/_redirects に 1 行置きます。
/* /200.html 200
古い記事は /index.html を指していることがあります。 素の Vite で作った SPA ならそれで正しいのですが、Nuxt の nuxt generate では 200.html です。index.html はトップページの中身なので、深い URL を開いたときにトップページが表示されてしまいます。
ただしこの 1 行は、存在しない URL も 200 で返すようにします。 404 を 404 のまま返したいなら、200.html へ向ける範囲を絞ってください。Git 連携で Netlify の preset が走る構成では、Nuxt 側が /* /404.html 404 を書きます。利用者側に /* の行があると、そちらが優先されて既定の 404 が消えます。
nuxt build で Node サーバーとして動かす場合、fallback の設定は要りません。すべてのリクエストがサーバーに届き、そこでルーティングされます。Vercel や Netlify に Git 連携で載せる場合は Nuxt が provider を検出して preset を選ぶので、vercel.json を自分で書く必要も基本的にありません。
この連載のまとめ
- この章が扱うのは cookie の受け渡しだけです。 トークンの発行と検証は省いています。
httpOnlyは読み取りを塞ぐだけで書き込みは塞がないので、サーバー側で毎回検証しなければ保護になりません - トークンを
localStorageに置かないでください。 OWASP が明示的に禁じており、Nuxt ではサーバー側にlocalStorageが無いので初回の HTML が必ず未ログイン状態になります httpOnlycookie はサーバーで発行します。 値をレスポンス本文に入れるとhttpOnlyにした意味がなくなります。secureは HTTPS でないと送り返されないのでimport.meta.devで切り替えます。httpOnlyが守るのは「値が漏れること」までで、注入されたスクリプトが自サイトへリクエストを撃つ経路は塞げません —sameSiteの指定が別に要りますuseCookieの見え方はサーバーとクライアントで違います。 サーバーはリクエストヘッダを読むのでhttpOnlyでも取れ、クライアントはdocument.cookieを読むので取れません。useCookie('token').valueを判定に使うとクライアント遷移で弾かれます — ログイン済みかどうかはuseStateに載せて payload で渡し、トークンの値そのものは載せません- サーバー専用の plugin はログインした直後には走りません。
$fetchが成功したその場でuseStateを更新してください。忘れると「一度リロードすれば直る」という症状になります。ログアウトも同じで、戻し忘れると cookie を消したのに画面がログイン済みのままになります redirectクエリをそのままnavigateToへ渡さないでください。 外部 URL が入っていると投げられ、ログインは成立したのに画面が動かない状態になります。//だけでなく/\で始まる形も別ホストへ向かいます- 遷移の保護は
app/middleware/のミドルウェアで行い、navigateToは必ずreturnします。返さないとガードは黙って素通りし、保護したはずのページが 200 で描かれます (サーバー側で実測)。返していればサーバーが 302 を返すので、ページの中身が一瞬見えることもありません - この章の形は、リクエストのたびにサーバーが動く構成が前提です。 判定材料を用意する
.serverplugin が動かないので、事前に HTML を作って配るだけの構成 (nuxt generate、ssr: falseのnuxt build) では最初の値が入りません。認証が要るページを持つなら既定のnuxt buildを選びます navigateToを呼んでも<script setup>の残りは止まりません。 続きを走らせたくないなら自分でreturnします。外部 URL にはexternal: trueが要りますnuxt buildは.output/に出力します。既定のnode-serverpreset ならnitro.json/public//server/で、.output/だけをコピーすればnode .output/server/index.mjsで動きます。preset を変えると構成が変わります。nuxt generateは.output/public/に静的ファイルと200.html/404.htmlを出します- 静的ホスティングの fallback は provider ごとに違います。 Nuxt の SSG では
index.htmlではなく200.htmlを指しますが、/*を丸ごと向けると 404 も 200 で返るようになります。Node サーバーとして動かすなら設定自体が要りません
- OWASP — HTML5 Security Cheat Sheet — Web Storage にセッション識別子を置かない理由
- Nuxt —
useCookie—httpOnly/secure/sameSiteの扱い - Nuxt —
defineNuxtRouteMiddleware— ミドルウェアの signature - Nuxt —
navigateTo—returnの必要性とexternal - Nuxt — Deployment —
.output/の中身と200.html/404.html
次に読む
この連載はここで終わりです。振り返るなら、仕組みの側は 2 章 (リアクティビティ)、設計の側は 8 章 (Composable) が土台になっています。遷移そのものの制御を深めたいなら 16 章 (ナビゲーションガード) が続きです。