【React】JWTの扱い方:ログイン状態を保持する仕組み

JavaScript

こんにちは、かつコーチです。

前回はReactアプリにおける認証の実装パターンを、全体像から解説しました。

今回はその認証の裏側で動いている仕組み、JWT(JSON Web Token)にフォーカスして、Reactアプリでどう扱えばよいかを詳しく見ていきます。

「なんとなくトークンをlocalStorageに入れている」という理解のままだと、セキュリティ事故につながりかねないテーマなので、しっかり押さえておきましょう。

JWTとは何か

構造:ヘッダー・ペイロード・署名

JWTは、「.」で区切られた3つの部分から成る文字列です。

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEyMywibmFtZSI6IuOBi-OBpOOCs-ODvOODgSJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

それぞれ次のような意味を持っています。

部分内容
ヘッダー使用している暗号化アルゴリズムなどの情報
ペイロードユーザーIDや権限など、実際のデータ(Base64でエンコードされているだけで暗号化はされていない)
署名サーバーの秘密鍵で生成された、改ざん検知用の値

ここで重要なのが、ペイロード部分は暗号化されておらず、誰でも中身をデコードして読めるという点です。

私は最初、「JWT=暗号化されているから安全」と勘違いしていましたが、実際は「改ざんされていないことを保証する」仕組みであって、「中身を隠す」仕組みではありません。

パスワードなどの機密情報をペイロードに含めてはいけない、というのはこの構造上の性質から来ています。

セッション認証との違い

従来型のセッション認証は、サーバー側がログイン状態を「セッションID」として保持し、DBやメモリでユーザー情報と紐づけて管理します。

一方JWTは、ユーザー情報そのものをトークンの中に詰め込んでクライアントに渡すため、サーバー側でログイン状態を保持する必要がありません(ステートレス)。

この性質のおかげで、サーバーを複数台に増やしても(スケールアウトしても)セッション情報を共有する仕組みが不要になる、というメリットがあります。

反面、一度発行したトークンをサーバー側で強制的に無効化するのが難しい、というデメリットもあわせて覚えておきましょう。

Reactでのトークンの扱い方

ログイン時にトークンを受け取り保存する

一般的なJWT認証では、ログインに成功するとサーバーからトークンが返ってきます。

type LoginResponse = {
  token: string;
  user: { id: number; name: string };
};

const login = async (email: string, password: string): Promise<LoginResponse> => {
  const res = await fetch("/api/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error("ログインに失敗しました");

  return res.json();
};

このトークンを、以降のAPIリクエストで身分証として使い回すことになります。

保存場所の選択:localStorage vs Cookie

問題は「このトークンをどこに保存するか」です。

保存先特徴
localStorageJavaScriptから自由に読み書きできる。実装は簡単だがXSS攻撃に弱い
httpOnly CookieJavaScriptからアクセスできない。XSSに強いがCSRF対策が別途必要

初心者向けの解説記事では手軽さからlocalStorageが使われがちですが、実務ではhttpOnly Cookieが推奨されるケースが多いです。

よくあるつまずきポイント:保存場所によるセキュリティリスク

localStorageに保存してXSSリスクを高めてしまう

私が個人開発でJWT認証を初めて実装したとき、何も考えずにlocalStorageにトークンを保存していました。

❌ Before:localStorageにトークンを保存する

const handleLoginSuccess = (token: string) => {
  localStorage.setItem("token", token);
};

const apiFetch = async (url: string) => {
  const token = localStorage.getItem("token");
  return fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  });
};

このコード自体は動きますが、localStorageはどのJavaScriptコードからでも読み取れる場所です。

つまり、もしアプリのどこか一箇所でもXSS(クロスサイトスクリプティング)の脆弱性があり、悪意あるスクリプトが実行されてしまうと、そのスクリプトがlocalStorage.getItem("token")を実行するだけでトークンを盗み出せてしまいます。

外部ライブラリの依存が多いReactアプリでは、意図せずXSSのリスクを抱え込んでしまう可能性はゼロではないため、私はこの点を指摘されて設計を見直した経験があります。

✅ After:httpOnly Cookieにサーバー側で保存させる

// クライアント側では「credentials: include」を指定するだけで、
// トークンの保存・送信はブラウザとサーバーのCookie機構に任せる
const login = async (email: string, password: string) => {
  const res = await fetch("/api/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include", // Cookieを送受信の対象にする
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error("ログインに失敗しました");

  return res.json();
};

const apiFetch = async (url: string) => {
  // Authorizationヘッダーを手動で付ける必要がない
  return fetch(url, { credentials: "include" });
};

サーバー側でSet-CookieヘッダーにHttpOnly属性をつけてトークンを発行すると、JavaScriptからはdocument.cookie経由でも中身を読み取れなくなります。

XSSが発生してもトークンそのものは盗まれにくくなるため、セキュリティ上はこちらが優先される設計です。

代わりにCSRF(クロスサイトリクエストフォージェリ)対策が別途必要になりますが、これはサーバー側でCSRFトークンを発行する仕組みとセットで導入するのが一般的です。

応用・一歩先の使い方

リクエストヘッダーへの自動付与

Authorizationヘッダー方式(localStorageに保存する場合)を採用するなら、毎回手動でヘッダーを付けるのではなく、共通のfetchラッパーにまとめておくとミスが減ります。

const apiFetch = async (url: string, options: RequestInit = {}) => {
  const token = localStorage.getItem("token");

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
  });
};

リフレッシュトークンで有効期限切れに対応する

JWTには通常、短い有効期限(例:15分〜1時間)が設定されます。

有効期限が切れるたびに毎回ログインし直すのはユーザー体験としてよくないため、有効期限の長いリフレッシュトークンを別途発行し、アクセストークンが切れたら裏側で自動的に再発行する仕組みがよく使われます。

const apiFetchWithRefresh = async (url: string, options?: RequestInit) => {
  let res = await fetch(url, { ...options, credentials: "include" });

  if (res.status === 401) {
    // リフレッシュトークンを使ってアクセストークンを再発行
    const refreshRes = await fetch("/api/refresh", { credentials: "include" });

    if (refreshRes.ok) {
      // 再発行に成功したら、元のリクエストをもう一度実行する
      res = await fetch(url, { ...options, credentials: "include" });
    } else {
      window.location.href = "/login";
    }
  }

  return res;
};

アクセストークンの短い有効期限による安全性と、リフレッシュトークンによる使い勝手の良さを両立させる、というのがこの設計の狙いです。

次回は、この認証の仕組みを使って「ログインしていないユーザーを特定のページに入れない」Protected Routeの実装方法を解説します。

まとめ

この記事のポイント

  • JWTはヘッダー・ペイロード・署名の3部構成で、ペイロードは暗号化されていない
  • セッション認証と異なりステートレスで、サーバー側にログイン状態を保持しない
  • localStorageへの保存は手軽だがXSSに弱く、httpOnly Cookieの方がセキュリティ上は優れる
  • Cookie方式を採用する場合は、CSRF対策もあわせて導入する
  • リフレッシュトークンを使うことで、セキュリティと使い勝手を両立できる

次に読むべき記事

→ 次の記事:Protected Route(ログイン必須ページ)の実装方法

タイトルとURLをコピーしました