Next.js JWT認証の実装|アクセストークンをメモリ管理・リフレッシュ並走バグを防ぐmutexパターン

  • Next.jsでJWTトークンをどこに保存すればいいか知りたい
  • 複数リクエストが同時にリフレッシュを呼んでエラーになる問題を解決したい
  • 401レスポンスを受け取ったときに自動でリトライする仕組みを作りたい

この記事は、Next.js + Django JWT認証シリーズのフロントエンド実装編です。設計の考え方は下記の記事で解説しています。

TypeScriptで実装します。ファイル名は lib/auth.ts として、認証に関わる関数をすべてここにまとめます。

アクセストークンをメモリに保持する

アクセストークンはモジュールスコープの変数に保存します。localStorageやsessionStorageを使わないことがXSS対策の核心です。

TypeScript
// モジュールスコープの変数でメモリ管理する
let accessTokenInMemory: string | null = null;

export const getAccessToken = () => accessTokenInMemory;
export const setAccessToken = (t: string | null) => {
  accessTokenInMemory = t;
};

モジュールスコープの変数はページをリロードすると消えます。ただし、有効なリフレッシュCookieがあればページ初期化時に自動で復元されます。この仕組みは後述の initializeAuth() で実装します。

localStorageはセッションフラグだけに使う

HttpOnly CookieはJavaScriptから読めないため、ログイン済みかどうかを確認する手段がありません。そこで「セッションが存在するかどうか」というboolean値だけをlocalStorageに保存します。トークン本体は絶対に保存しません。

TypeScript
const SESSION_FLAG = 'has_session';

// ログイン時に呼ぶ
export const markSessionActive = () => {
  try { localStorage.setItem(SESSION_FLAG, '1'); } catch { /* ignore */ }
};

// ログアウト時に呼ぶ
export const clearSessionFlag = () => {
  try { localStorage.removeItem(SESSION_FLAG); } catch { /* ignore */ }
};

// ログイン済みかどうかのヒントとして使う
export const hasSessionFlag = (): boolean => {
  try { return localStorage.getItem(SESSION_FLAG) === '1'; } catch { return false; }
};

このフラグはUIの表示切り替え(ログイン状態の初期判定)に使うもので、実際のAPIリクエストの認証はメモリ上のアクセストークンで行います。

CSRF トークンの取得

DjangoはCSRF保護が有効なため、POSTリクエストには X-CSRFToken ヘッダーが必要です。まずCookie からCSRFトークンを読む関数を用意し、次にCSRF Cookieを取得するエンドポイントを呼び出す関数を作ります。

TypeScript
// document.cookie からCSRFトークンを読む
const getCookie = (name: string): string | null => {
  const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
  return m ? decodeURIComponent(m[1]) : null
}

// ページ初期化時に1回だけ呼んでCSRF Cookieをセットする
export const fetchCsrfCookie = async () => {
  try {
    await fetch('/auth/csrf_cookie/', {
      method: 'GET',
      credentials: 'include',
    })
  } catch {
    // 広告ブロッカー等でブロックされる可能性があるため静かに失敗させる
  }
}

refreshToken() の並走対策(mutex)

ここがこの実装の核心部分です。ROTATE_REFRESH_TOKENS = True の環境では、複数のリクエストが同時に refreshToken() を呼び出すと競合が起きます。

対策は、進行中のリフレッシュリクエストをモジュールスコープ変数に保持し、2回目以降の呼び出しでは同じPromiseを返すことです。

TypeScript
// 進行中のリフレッシュリクエストを保持する(mutexとして機能する)
let refreshingPromise: Promise<boolean> | null = null;

export const refreshToken = (): Promise<boolean> => {
  // 既に進行中なら同じPromiseを返す(新たにリクエストを送らない)
  if (refreshingPromise) return refreshingPromise;

  refreshingPromise = (async () => {
    try {
      const res = await fetch('/auth/jwt/refresh/', {
        method: 'POST',
        credentials: 'include', // HttpOnly Cookieをブラウザが自動送信
        headers: {
          'Content-Type': 'application/json',
          'X-CSRFToken': getCookie('csrftoken') ?? '',
        },
        body: JSON.stringify({}),
      });
      if (!res.ok) return false;
      const data = await res.json() as { access?: string };
      if (data?.access) setAccessToken(data.access);
      return !!data?.access;
    } catch {
      return false;
    } finally {
      // 成功・失敗にかかわらず次の呼び出しに備えてリセット
      refreshingPromise = null;
    }
  })();

  return refreshingPromise;
};

3つのリクエストが同時に401を受け取って refreshToken() を呼び出したとしても、実際にサーバーへ送るリフレッシュリクエストは1本だけになります。他の2本は同じPromiseを待つため、競合が起きません。

apiFetch() で 401 を自動リトライする

APIリクエストのラッパー関数を実装します。401レスポンスを受け取ったら自動でリフレッシュし、元のリクエストを再実行します。

TypeScript
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';

export async function apiFetch(input: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  const token = getAccessToken();
  if (token) headers.set('Authorization', `Bearer ${token}`);

  let res = await fetch(`${API_BASE_URL}${input}`, {
    ...init,
    headers,
    credentials: 'include',
  });

  // 401以外はそのまま返す
  if (res.status !== 401) return res;

  // アクセストークン切れ → リフレッシュしてリトライ
  const refreshed = await refreshToken();
  if (!refreshed) return res; // リフレッシュも失敗したら元の401を返す

  // 新しいアクセストークンで再リクエスト
  headers.set('Authorization', `Bearer ${getAccessToken()}`);
  return fetch(`${API_BASE_URL}${input}`, { ...init, headers, credentials: 'include' });
}

呼び出し側は通常の fetch() と同じ感覚で使えます。401ハンドリングを意識する必要がなく、コードがシンプルに保てます。

ログイン・ログアウトの実装

ログインはDjoserの /auth/jwt/create/ を呼び出します。アクセストークンをメモリに保存し、セッションフラグを立てます。

TypeScript
export const login = async (email: string, password: string) => {
  const res = await fetch(`${API_BASE_URL}/auth/jwt/create/`, {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRFToken': getCookie('csrftoken') ?? '',
    },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error('ログインに失敗しました');
  const data = await res.json() as { access: string };
  setAccessToken(data.access);
  markSessionActive();
  return data;
};

export const logout = async () => {
  try {
    await fetch(`${API_BASE_URL}/auth/logout/`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'X-CSRFToken': getCookie('csrftoken') ?? '' },
    });
  } finally {
    // サーバー側の処理が失敗してもローカルはクリアする
    setAccessToken(null);
    clearSessionFlag();
  }
};

ログアウトは try...finally を使い、サーバー側のブラックリスト登録が失敗してもローカルのアクセストークンとセッションフラグは必ずクリアします。

ページ初期化時の認証復元(initializeAuth)

ページをリロードするとメモリ上のアクセストークンが消えます。有効なリフレッシュCookieがあれば自動で復元できるよう、アプリ起動時に一度だけ呼ぶ初期化関数を用意します。

TypeScript
export const initializeAuth = async (): Promise<void> => {
  try {
    // 1. CSRF Cookieを取得する(POSTリクエストに必要)
    await fetchCsrfCookie();
    // 2. リフレッシュCookieでアクセストークンを復元する
    await refreshToken();
  } catch (e) {
    console.warn('initializeAuth failed', e);
  }
};

Next.jsのApp Routerを使っている場合、ルートの layout.tsx に配置した AuthProvider などのクライアントコンポーネントから useEffect(() => { initializeAuth(); }, []) で呼び出します。

リフレッシュCookieが有効期限切れの場合は refreshToken()false を返します。この場合はアクセストークンが復元されず、ユーザーは未ログイン状態になります。

まとめ:この実装で防げること

  • XSSによるトークン盗取
    アクセストークンはメモリのみ。リフレッシュトークンはHttpOnly Cookie。どちらもXSSスクリプトから直接読み出せない。
  • CSRF攻撃
    アクセストークンはAuthorizationヘッダー送信のみ。リフレッシュCookieのパスは /auth/ に限定されているため、API全体をCSRFで叩くことができない。
  • リフレッシュの並走競合
    mutexパターンにより、同時に何本のリクエストが来ても実際のリフレッシュAPIは1本のみ実行される。
  • アクセストークン期限切れによるエラー
    apiFetch() が自動でリフレッシュ&リトライするため、呼び出し側で期限切れを意識しなくてよい。

バックエンド(Django)側の設定は下記の記事で解説しています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

普段は主にPython開発をしています。
最近はAI駆動開発にも関わっています。

コメント

コメントする