空雲 Blog

Eye catch
Next.js で Service Worker を使った Cookie フリー SSR 認証の実装

publication: 2026/05/28
update:2026/05/28


Webの認証といえばCookie(HttpOnly)を使っておけばまず安全、というのが定番です。ただ、これがスマホアプリのWebViewだったり、クロスドメインでAPIサーバーと通信したりする状況になると、急にCookieの管理が面倒になります。SameSite属性の考慮が必要だったり、ブラウザのサードパーティCookie規制でトークンが消えたりしてハマった経験がある方も多いのではないでしょうか。


そこでCookieを使わずに localStorageIndexedDB にトークンを置くアプローチを取るのですが、そうすると今度はSSR(Server-Side Rendering)の段階でトークンをサーバーへ送れなくなります。最初のドキュメントリクエストの時点ではJavaScriptが動かないので、リクエストヘッダーにトークンを載せられず、結果として初期描画をサーバー側で制御できなくなってしまいます。


この問題をなんとか解決できないかと考えて実装したのが、Service WorkerIndexedDB を組み合わせて、ドキュメントリクエストそのものを横取りしてヘッダーを注入するアプローチです。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;
4
5function 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}
22
23export 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}
33
34export 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}
48
49export 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;
4
5// インストール後、待機せずに即座にアクティブ化
6self.addEventListener('install', () => {
7 self.skipWaiting();
8});
9
10// アクティブ化されたら、制御下にある全クライアントを即座に引き受ける
11self.addEventListener('activate', (event) => {
12 event.waitUntil(self.clients.claim());
13});
14
15// 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}
44
45// リクエストの傍受
46self.addEventListener('fetch', (event) => {
47 const url = new URL(event.request.url);
48
49 // 同一オリジンのリクエストのみを対象にする
50 if (url.origin !== self.location.origin) {
51 return;
52 }
53
54 // 静的アセット、特定ビルドファイル、webpackのHMR用ソケット通信はスキップ
55 if (
56 url.pathname.startsWith('/_next/static/') ||
57 url.pathname.includes('.') ||
58 url.pathname.includes('/webpack-hmr')
59 ) {
60 return;
61 }
62
63 // ドキュメント遷移、APIリクエスト、RSC通信などを対象にインターセプト
64 // このデモでは「同一オリジンかつ静的アセットではないもの」を対象にする
65 event.respondWith(
66 (async () => {
67 const token = await getTokenFromIndexedDB();
68
69 // ヘッダーをクローンし、トークンが存在すれば Authorization を設定
70 const headers = new Headers(event.request.headers);
71 if (token) {
72 headers.set('Authorization', `Bearer ${token}`);
73 }
74
75 // 変更後のリクエストオプションを構築
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 };
84
85 // 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 }
93
94 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 を組み立て直していますが、実運用では cacheredirectreferrerintegrity などのリクエスト情報を落とさないように注意が必要です。対象をドキュメントリクエストに絞る、または new Request(event.request, { headers }) のように元のRequestをベースに複製するほうが安全なケースもあります。


3. Service Worker の登録と監視 (src/components/ServiceWorkerRegister.tsx)🔗


クライアントサイドでSWをアクティブ化しつつ、登録状況を画面右上に見やすく表示するデバッグ用のコンポーネントです。


1'use client';
2
3import React, { useEffect, useState } from 'react';
4
5export 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 });
12
13 useEffect(() => {
14 if (swStatus === 'unsupported' || swStatus === 'active') {
15 return;
16 }
17
18 const registerSW = async () => {
19 try {
20 const registration = await navigator.serviceWorker.register('/sw.js');
21 console.log('[Register] SW registered:', registration);
22
23 if (navigator.serviceWorker.controller) {
24 setSwStatus('active');
25 return;
26 }
27
28 // 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 };
34
35 navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
36 } catch (error) {
37 console.error('[Register] SW registration failed:', error);
38 setSwStatus('error');
39 }
40 };
41
42 registerSW();
43 }, [swStatus]);
44
45 return (
46 <>
47 <div
48 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';
4
5// サーバーサイドでのモックユーザー検証
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}
17
18export default async function RootPage() {
19 const headersList = await headers();
20 const cookiesList = await cookies();
21
22 const authHeader = headersList.get('authorization');
23 let user = null;
24 let token = null;
25
26 if (authHeader && authHeader.startsWith('Bearer ')) {
27 token = authHeader.substring(7);
28 user = await verifyUser(token);
29 }
30
31 // デモ上、Cookie が空であることを画面に表示するために取得
32 const allCookies = cookiesList.getAll();
33 const cookieDisplay = allCookies.length > 0
34 ? allCookies.map(c => `${c.name}=${c.value}`).join('; ')
35 : '(empty)';
36
37 if (user && token) {
38 return (
39 <DashboardView
40 token={token}
41 user={user}
42 cookieDisplay={cookieDisplay}
43 />
44 );
45 }
46
47 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関連のパスを避けていますが、プロダクションに載せる際はアセットの判定に少し気を使います。




終わりに🔗


出来そうだからやってみただけなので、本気でやろうとは思わないほうが無難です