Next.js で Service Worker を使った Cookie フリー SSR 認証の実装
Webの認証といえばCookie(HttpOnly)を使っておけばまず安全、というのが定番です。ただ、これがスマホアプリのWebViewだったり、クロスドメインでAPIサーバーと通信したりする状況になると、急にCookieの管理が面倒になります。SameSite属性の考慮が必要だったり、ブラウザのサードパーティCookie規制でトークンが消えたりしてハマった経験がある方も多いのではないでしょうか。
そこでCookieを使わずに localStorage や IndexedDB にトークンを置くアプローチを取るのですが、そうすると今度はSSR(Server-Side Rendering)の段階でトークンをサーバーへ送れなくなります。最初のドキュメントリクエストの時点ではJavaScriptが動かないので、リクエストヘッダーにトークンを載せられず、結果として初期描画をサーバー側で制御できなくなってしまいます。
この問題をなんとか解決できないかと考えて実装したのが、Service Worker と IndexedDB を組み合わせて、ドキュメントリクエストそのものを横取りしてヘッダーを注入するアプローチです。Next.js (App Router) を使い、Cookieレスでありながら同一URLでSSR時の認証分岐を実現するデモを作ってみました。
※ 普通にCookieを使える環境なら、ぶっちゃけこのやり方は実装を複雑化させるだけなので、メリットがありません
仕組みと動作フロー🔗
この仕組みの要は、ブラウザとサーバーの間にService Worker(SW)を挟み込む点にあります。SWが制御しているページから発生する同一オリジンのリクエスト(画面遷移時のドキュメント取得も含む)をプロキシし、IndexedDBから引っ張ってきたトークンを Authorization: Bearer <token> ヘッダーとして動的に差し込みます。
ただし、SWは初回アクセスのドキュメントリクエストをさかのぼって制御できるわけではありません。このアプローチは、SWがインストールされてページを制御できる状態になった後のリロードや画面遷移で効いてくる仕組みです。
全体の動きは以下の通りです。
sequenceDiagram
autonumber
actor User as ユーザー / ブラウザ
participant SW as Service Worker
participant IDB as IndexedDB
participant Server as Next.js SSR Server (GET /)
%% 1. 初回アクセス (SW未起動・未ログイン)
User->>Server: 初回アクセス (GET /)
Note over Server: ヘッダーにトークンなし
Server-->>User: ログインフォーム HTML 返却
Note over User: SW がマウント後にバックグラウンドで起動・アクティブ化
%% 2. ログイン処理
User->>Server: ログインAPI実行 (POST /api/auth/login)
Server-->>User: トークン返却
User->>IDB: トークンを保存 (setItem)
User->>User: ページリロード実行 (window.location.reload)
%% 3. 認証後リロード (SWアクティブ)
User->>SW: ページリクエスト (GET /)
SW->>IDB: トークンを取得
IDB-->>SW: トークン返却
Note over SW: Authorizationヘッダーを追加
SW->>Server: GET / (Header: Authorization)
Note over Server: ヘッダーからトークン確認・検証成功
Server-->>SW: ダッシュボード HTML 返却
SW-->>User: ダッシュボード表示
ログインからSSRの切り替えまで、URLは一切変わらず / のままです。Cookieは1バイトも使っていません。
主要コードの実装🔗
デモアプリを構成するコアコードを見ていきます。
1. IndexedDB ユーティリティ (src/utils/db.ts)🔗
まずはメインスレッドとService Workerの双方からアクセスできる共通ストレージとして、IndexedDBを使います。メインスレッド側では簡単なラッパーを用意しました。SW側からも同じDB名・Store名でアクセスできるようにしておきます。
1const DB_NAME = 'next-sw-auth-db';2const STORE_NAME = 'auth-store';3const DB_VERSION = 1;45function getDB(): Promise<IDBDatabase> {6 return new Promise((resolve, reject) => {7 if (typeof indexedDB === 'undefined') {8 reject(new Error('IndexedDB is not supported in this environment.'));9 return;10 }11 const request = indexedDB.open(DB_NAME, DB_VERSION);12 request.onerror = () => reject(request.error);13 request.onsuccess = () => resolve(request.result);14 request.onupgradeneeded = () => {15 const db = request.result;16 if (!db.objectStoreNames.contains(STORE_NAME)) {17 db.createObjectStore(STORE_NAME);18 }19 };20 });21}2223export async function setItem(key: string, value: any): Promise<void> {24 const db = await getDB();25 return new Promise((resolve, reject) => {26 const transaction = db.transaction(STORE_NAME, 'readwrite');27 const store = transaction.objectStore(STORE_NAME);28 const request = store.put(value, key);29 request.onsuccess = () => resolve();30 request.onerror = () => reject(request.error);31 });32}3334export async function getItem<T>(key: string): Promise<T | null> {35 try {36 const db = await getDB();37 return new Promise((resolve, reject) => {38 const transaction = db.transaction(STORE_NAME, 'readonly');39 const store = transaction.objectStore(STORE_NAME);40 const request = store.get(key);41 request.onsuccess = () => resolve(request.result || null);42 request.onerror = () => reject(request.error);43 });44 } catch (error) {45 return null;46 }47}4849export async function removeItem(key: string): Promise<void> {50 const db = await getDB();51 return new Promise((resolve, reject) => {52 const transaction = db.transaction(STORE_NAME, 'readwrite');53 const store = transaction.objectStore(STORE_NAME);54 const request = store.delete(key);55 request.onsuccess = () => resolve();56 request.onerror = () => reject(request.error);57 });58}
2. Service Worker でのリクエスト傍受とヘッダー注入 (public/sw.js)🔗
ここが一番のポイントです。SWの fetch イベントで通信をフックし、必要なリクエストに対してだけIndexedDBのトークンを乗せます。
1const DB_NAME = 'next-sw-auth-db';2const STORE_NAME = 'auth-store';3const DB_VERSION = 1;45// インストール後、待機せずに即座にアクティブ化6self.addEventListener('install', () => {7 self.skipWaiting();8});910// アクティブ化されたら、制御下にある全クライアントを即座に引き受ける11self.addEventListener('activate', (event) => {12 event.waitUntil(self.clients.claim());13});1415// IndexedDB からアクセストークンを取得するヘルパー16function getTokenFromIndexedDB() {17 return new Promise((resolve) => {18 const request = indexedDB.open(DB_NAME, DB_VERSION);19 request.onerror = () => resolve(null);20 request.onsuccess = () => {21 const db = request.result;22 if (!db.objectStoreNames.contains(STORE_NAME)) {23 resolve(null);24 return;25 }26 try {27 const transaction = db.transaction(STORE_NAME, 'readonly');28 const store = transaction.objectStore(STORE_NAME);29 const getRequest = store.get('accessToken');30 getRequest.onsuccess = () => resolve(getRequest.result || null);31 getRequest.onerror = () => resolve(null);32 } catch (e) {33 resolve(null);34 }35 };36 request.onupgradeneeded = () => {37 const db = request.result;38 if (!db.objectStoreNames.contains(STORE_NAME)) {39 db.createObjectStore(STORE_NAME);40 }41 };42 });43}4445// リクエストの傍受46self.addEventListener('fetch', (event) => {47 const url = new URL(event.request.url);4849 // 同一オリジンのリクエストのみを対象にする50 if (url.origin !== self.location.origin) {51 return;52 }5354 // 静的アセット、特定ビルドファイル、webpackのHMR用ソケット通信はスキップ55 if (56 url.pathname.startsWith('/_next/static/') ||57 url.pathname.includes('.') ||58 url.pathname.includes('/webpack-hmr')59 ) {60 return;61 }6263 // ドキュメント遷移、APIリクエスト、RSC通信などを対象にインターセプト64 // このデモでは「同一オリジンかつ静的アセットではないもの」を対象にする65 event.respondWith(66 (async () => {67 const token = await getTokenFromIndexedDB();6869 // ヘッダーをクローンし、トークンが存在すれば Authorization を設定70 const headers = new Headers(event.request.headers);71 if (token) {72 headers.set('Authorization', `Bearer ${token}`);73 }7475 // 変更後のリクエストオプションを構築76 const requestInit = {77 method: event.request.method,78 headers: headers,79 credentials: event.request.credentials,80 // ナビゲーションリクエスト(HTMLドキュメント取得)の mode は 'navigate' だが、81 // fetchで中継する際はセキュリティの都合上 'same-origin' 等に書き換える82 mode: event.request.mode === 'navigate' ? 'same-origin' : event.request.mode,83 };8485 // GETやHEAD以外のリクエスト(POSTやPUTなど)の場合、ボディをクローンして引き継ぐ86 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {87 try {88 requestInit.body = await event.request.clone().blob();89 } catch (e) {90 console.error('[SW] Failed to clone request body:', e);91 }92 }9394 try {95 // Authorization ヘッダーを付与したリクエストをサーバーに送信96 const response = await fetch(event.request.url, requestInit);97 return response;98 } catch (error) {99 console.error('[SW] Fetch proxy failed, falling back to original request:', error);100 return fetch(event.request);101 }102 })()103 );104});
静的アセット(/_next/static/ や .css、.png など)やNext.jsの開発用ホットリロードソケットまで処理するとパフォーマンスが落ちるため、これらは最初に除外しておきます。今回のデモでは単純に「パスに . を含むもの」を除外していますが、実運用ではAPIやファイル配信のURL設計に合わせて条件を調整してください。
また、skipWaiting() と self.clients.claim() を使って、SWがインストールされた後にページを制御しやすくしています。ただし、これでも初回のトップレベルナビゲーションそのものは制御できないため、初回だけ未認証表示になる可能性は残ります。
なお、このサンプルでは RequestInit を組み立て直していますが、実運用では cache、redirect、referrer、integrity などのリクエスト情報を落とさないように注意が必要です。対象をドキュメントリクエストに絞る、または new Request(event.request, { headers }) のように元のRequestをベースに複製するほうが安全なケースもあります。
3. Service Worker の登録と監視 (src/components/ServiceWorkerRegister.tsx)🔗
クライアントサイドでSWをアクティブ化しつつ、登録状況を画面右上に見やすく表示するデバッグ用のコンポーネントです。
1'use client';23import React, { useEffect, useState } from 'react';45export default function ServiceWorkerRegister({ children }: { children: React.ReactNode }) {6 const [swStatus, setSwStatus] = useState<'initializing' | 'active' | 'unsupported' | 'error'>(() => {7 if (typeof window === 'undefined') return 'initializing';8 if (!('serviceWorker' in navigator)) return 'unsupported';9 if (navigator.serviceWorker.controller) return 'active';10 return 'initializing';11 });1213 useEffect(() => {14 if (swStatus === 'unsupported' || swStatus === 'active') {15 return;16 }1718 const registerSW = async () => {19 try {20 const registration = await navigator.serviceWorker.register('/sw.js');21 console.log('[Register] SW registered:', registration);2223 if (navigator.serviceWorker.controller) {24 setSwStatus('active');25 return;26 }2728 // Service Worker が実際にページをコントロールし始める瞬間を監視29 const onControllerChange = () => {30 console.log('[Register] Controller changed, SW now in control');31 setSwStatus('active');32 navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);33 };3435 navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);36 } catch (error) {37 console.error('[Register] SW registration failed:', error);38 setSwStatus('error');39 }40 };4142 registerSW();43 }, [swStatus]);4445 return (46 <>47 <div48 style={{49 position: 'fixed',50 top: '1rem',51 right: '1rem',52 zIndex: 9999,53 padding: '0.4rem 0.85rem',54 borderRadius: '9999px',55 fontSize: '0.75rem',56 fontWeight: 700,57 fontFamily: 'monospace',58 background: swStatus === 'active' ? 'rgba(16, 185, 129, 0.15)' : 'rgba(245, 158, 11, 0.15)',59 border: `1px solid ${swStatus === 'active' ? '#10b981' : '#f59e0b'}`,60 color: swStatus === 'active' ? '#10b981' : '#f59e0b',61 backdropFilter: 'blur(8px)',62 pointerEvents: 'none',63 boxShadow: '0 4px 12px rgba(0,0,0,0.1)',64 }}65 >66 🛡️ SW: {swStatus.toUpperCase()}67 </div>68 {children}69 </>70 );71}
RSCでそのままHTMLを描画しつつ、マウント後にバックグラウンドで登録が走ります。controllerchange イベントを監視して、SWが本当に制御を開始したタイミングでバッジを ACTIVE に切り替えています。
4. Next.js サーバーサイドでの認証検証 (src/app/page.tsx)🔗
サーバー側(React Server Components)では、受け取ったリクエストの headers から authorization を取得するだけで認証状態の判定が可能です。Cookieの読み取りは一切不要です。
1import { headers, cookies } from 'next/headers';2import LoginForm from '@/components/LoginForm';3import DashboardView from '@/components/DashboardView';45// サーバーサイドでのモックユーザー検証6async function verifyUser(token: string) {7 if (token === 'mock-session-token-xyz789') {8 return {9 name: 'Demo Admin',10 role: 'Administrator',11 email: 'admin@demo-sandbox.io',12 bio: 'このコンテンツは、同一のルートURL (/) でサーバーサイドレンダリング (SSR) されました。ページリロード時に、Service Worker が IndexedDB からトークンを取得し、Authorization ヘッダーを動的に注入しています。',13 };14 }15 return null;16}1718export default async function RootPage() {19 const headersList = await headers();20 const cookiesList = await cookies();2122 const authHeader = headersList.get('authorization');23 let user = null;24 let token = null;2526 if (authHeader && authHeader.startsWith('Bearer ')) {27 token = authHeader.substring(7);28 user = await verifyUser(token);29 }3031 // デモ上、Cookie が空であることを画面に表示するために取得32 const allCookies = cookiesList.getAll();33 const cookieDisplay = allCookies.length > 034 ? allCookies.map(c => `${c.name}=${c.value}`).join('; ')35 : '(empty)';3637 if (user && token) {38 return (39 <DashboardView40 token={token}41 user={user}42 cookieDisplay={cookieDisplay}43 />44 );45 }4647 return <LoginForm />;48}
ここではダミーの検証ロジックを呼んでいますが、実際のアプリなら外部のAPIサーバーや認証プロバイダと連携させる部分ですね。cookies() はデモ表示のために呼んでいるだけで、認証判定自体はCookieに依存していません。Cookieが空でも、サーバーはヘッダー経由でトークンを確認できています。
ページリロードとログイン・ログアウトの制御🔗
この設計で重要になるのが、ログイン・ログアウトの瞬間の制御です。
ログインが成功した後は、単にSPA的な遷移をするのではなく、window.location.reload() でページそのものをリロードさせます。
1await setItem('accessToken', data.token);2window.location.reload();
これによりブラウザが再度 / にアクセスし、アクティブになったSWがリクエストを横取りしてトークン付きでサーバーへ転送します。結果、サーバーは即座に認証後のUI(Dashboard)を描画できます。
ログアウト時は、IndexedDBからトークンを消した後に window.location.href = '/' でリダイレクトします。
1await removeItem('accessToken');2window.location.href = '/';
SWがヘッダーを載せずにサーバーへ要求を投げ直すため、サーバー側で自動的にログインフォームへ表示が切り替わる仕組みです。
Cookieフリー認証のメリットと考慮すべき点🔗
実際に作ってみて感じたメリットと、考慮すべき課題です。
メリット🔗
- CookieベースのCSRFリスクを受けにくい: Cookieの自動送信に依存しないため、典型的なCSRFの影響を受けにくくなります。
- ドメインの壁がない: WebアプリとAPIサーバーのドメインが分かれていても、クロスドメインCookieの設定に悩まされずに済みます。
- サードパーティCookie制限の影響を受けにくい: WebViewやプライバシー重視のブラウザなど、Cookie規制が厳しいクライアント環境下でも設計しやすくなります。
気をつけるべき課題🔗
- 初回アクセスの壁: ユーザーが初めてページを開いた瞬間は、まだSWがインストール&起動していません。そのため、初回のドキュメントリクエストにはトークンが載らず、サーバーは未認証として振る舞うことになります。初回だけはクライアント側でIndexedDBを見てローディングを挟むなど、何らかのフォールバックが必要です。
- XSSとの戦い:
HttpOnlyなCookieと違って、IndexedDBはJSから読めてしまいます。XSS脆弱性があるとトークンが抜かれるリスクがあるため、アクセストークンの寿命を数分程度に短くする、Refresh Tokenをより保護された経路で扱う、ネイティブアプリ側の安全なストレージと組み合わせるなど、トークン運用をきっちり設計しておくことが大前提になります。 - 開発時のキャッシュの考慮: Next.jsのHMRなどと競合しないよう、SWのバイパス設定を細かく書いておく必要があります。今回のコードでもwebpack関連のパスを避けていますが、プロダクションに載せる際はアセットの判定に少し気を使います。
終わりに🔗
出来そうだからやってみただけなので、本気でやろうとは思わないほうが無難です