Reactでの認証時にJWTをCookieに設定する方法

12 min read読了の目安(約11200字

SPAでの認証といえばJWTを使うことが多いと思いますが、
localStorageに保存するとセキュリティリスクが高いとかで、
CookieにHttpOnlyな値として保存するのが良いとしばしば言われることもあります。
今回はReact × ExpressでJWTをCookieに保存する具体的な方法を紹介します。

(そもそもJWTを使うべきかとか、localStorageを使うことのリスクなどについては要件次第なのであまり言及しません)

調査にあたっては以下の記事を参考にしました。
React Authentication: How to Store JWT in a Cookie

記事の方法そのままでは自分の環境では上手くいかなかったので、ハマりポイントも含めて手順を解説します。

最終的に出来上がったもの

動作環境

以下のDockerイメージを使用して挙動を確認しました。
node:15.5.1-alpine3.12

準備編

まずはlocalStorageにJWTを保存して動くサンプルアプリケーションを用意します。
上記の参考記事を見てもらっても良いですが、
こちらで用意した以下のリポジトリを見てもらっても良いです。
本記事ではこちらに準じて進めます。

Reactの部分だけTypeScriptを使用 + Dockerを使った構成

https://github.com/Kanatani28/jwt-how-to-use

(ちなみに自前でプロジェクトを作成したい場合はcreate-react-appでプロジェクトを作成して、各種ライブラリをインストールしてください。)

ソースコードは以下のようになっています。

App.tsx
import React, { useState } from 'react';
import axios from 'axios';
import './App.css';

const apiUrl = 'http://localhost:3001';

axios.interceptors.request.use(
  // allowedOriginと通信するときにトークンを付与するようにする設定
  config => {
    const { origin } = new URL(config.url as string);
    const allowedOrigins = [apiUrl];
    const token = localStorage.getItem('token');
    if (allowedOrigins.includes(origin)) {
      config.headers.authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

type Food = {
  id: number
  description: string
}

function App() {
  const storedJwt = localStorage.getItem('token');
  const [jwt, setJwt] = useState(storedJwt || null);
  const [foods, setFoods] = useState<Food[]>([]);
  const [fetchError, setFetchError] = useState(null);
  
  const getJwt = async () => {
    const { data } = await axios.get(`${apiUrl}/jwt`);
    localStorage.setItem('token', data.token);
    setJwt(data.token);
  };

  const getFoods = async () => {
    try {
      const { data } = await axios.get(`${apiUrl}/foods`);
      setFoods(data);
      setFetchError(null);
    } catch (err) {
      setFetchError(err.message);
    }
  };
  
  return (
    <>
      <section style={{ marginBottom: '10px' }}>
        <button onClick={() => getJwt()}>Get JWT</button>
        {jwt && (
          <pre>
            <code>{jwt}</code>
          </pre>
        )}
      </section>
      <section>
        <button onClick={() => getFoods()}>
          Get Foods
        </button>
        <ul>
          {foods.map((food, i) => (
            <li>{food.description}</li>
          ))}
        </ul>
        {fetchError && (
          <p style={{ color: 'red' }}>{fetchError}</p>
        )}
      </section>
    </>
  );
}
export default App;
server.js
const express = require('express');
const jwt = require('express-jwt');
const jsonwebtoken = require('jsonwebtoken');
const cors = require('cors');

const app = express();

app.use(cors());

const jwtSecret = 'secret123';

app.get('/jwt', (req, res) => {
  // JWTを生成する(今回は固定値で作成している)
  res.json({
    token: jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret)
  });
});

app.use(jwt({ secret: jwtSecret, algorithms: ['HS256'] }));

const foods = [
  { id: 1, description: 'burritos' },
  { id: 2, description: 'quesadillas' },
  { id: 3, description: 'churos' }
];

app.get('/foods', (req, res) => {
  res.json(foods);
});

app.listen(3001);
console.log('App running on localhost:3001');

アプリケーション概要

server.jsには/jwt/foodsという2つのエンドポイントを用意しています。
/jwtはJWTを、/foodsはJSONデータを返します。
App.tsxではボタンを2つ用意し、それぞれボタンを押したタイミングでサーバーと通信するようにしています。

docker-compose upを実行するとlocalhost:3000でReactのアプリケーションが立ち上がり、
その後docker-compose exec front node src/server.jsを実行すると
localhost:3001でNode.jsのアプリケーションが立ち上がります。

localhost:3000にアクセスすると以下のような画面が表示されるはずです。

いきなりGet Foodsボタンを押すと401エラーが表示され、
Get JWTでJWTを取得後、Get Foodsボタンを押すと、今度は正常に通信できるはずです。

  • JWTなしで通信

  • JWTありで通信

localStorageを確認してみる

Chromeの開発者ツール > Applicationを開くとlocalStorageに取得したtokenが設定されているのが確認できます。

localStorageに保存されているので、当然JavaScriptで取得することができます。

localStorage.getItem("token")

この状態があまりよろしくないので修正していきます。

修正編

JWTをCookieに保存する

まず最初にserver.jsのJWTを発行する部分を修正していきます。

そもそもCookieの仕組みって?

図にすると以下のようになります。
(知ってるよって人はスキップしてください)

サーバーからのレスポンスヘッダーにSet-Cookieという値が設定されていた場合、
クライアントのCookieにその値がセットされます。
以降そのサーバーとの通信ではセットされたCookieの値が付与されることになります。
フルスタックなフレームワークだとこういった仕組みを提供しているものが多いです。

Set-Cookieヘッダーを付与するようにする

Cookieをセットするためには、サーバーのレスポンスにSet-Cookieヘッダーを含める必要があります。
Cookieを使うため、JWT取得時に以下のようにSet-Cookieヘッダーを含めてレスポンスを返すようにします。

server.js
app.get('/jwt', (req, res) => {
  const token = jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret);
  // Set-Cookieヘッダーにtokenをセットする処理
  res.cookie('token', token, { httpOnly: true });
  res.json({ token });
});

今回はHttpOnlyをtrueとしているため、document.cookieのようなJavaScriptからはアクセスできず、
基本的にはHTTP通信するときのみ参照できるようになっています。
(HttpOnlyを設定していない場合はセキュリティ的にはlocalStorageに保存する方法と大差ないかと思います)

CORS対応する(ハマりポイント)

こちらは元記事にはなかった手順になります。
SPAではよくある構成かと思いますが、今回はlocalhost:3000localhost:3001
クロスオリジンでアプリケーションを起動しています。
クロスオリジンでCookieを使用する場合、いくつか設定が必要になります。

server.jsのcorsを設定している部分を以下のように修正します。

server.js
app.use(cors({
  credentials: true,
  origin: "http://localhost:3000"
}));

これでlocalhost:3000で起動しているアプリケーションともCookieをやり取りすることができるようになります。

また、App.tsxの方にも以下を追記します。

App.tsx
axios.defaults.withCredentials = true;

今回はサーバーとの通信にaxiosを使用していますが、
axiosはデフォルトではCookieを使う設定になっていないので、
上記のようにwithCredentialsをtrueにすることで通信時にCookieを送信できるようになります。

ここまで設定できたら再度アプリケーションを動かしてみましょう。
Get JWTボタンを押すとJWTが取得でき、開発者ツールで確認すると
Cookieにtokenが設定できているはずです。

Cookieに設定されたtokenを検証するようにする

server.jsでApp.tsxからのリクエスト時にCookieに設定されたtokenを検証する処理を追記・修正します。

まずは新しくcookie-parserというライブラリを追加します。

docker-compose exec front yarn add cookie-parser

次にserver.jsを以下のように修正します。

server.js
const cookieParser = require('cookie-parser');
// 略
app.use(cookieParser());
app.use(jwt({
  secret: jwtSecret,
  algorithms: ['HS256'],
  getToken: req => req.cookies.token 
}));

expressではcookie-parserを使用することでRequestに含まれるCookieを簡単に取得できるようになります。(req.cookies.tokenの部分)
また、検証もexpress-jwtを使うことで手軽にできるようになります。
getTokenで設定した関数でトークンを取得し、secretに設定した値を使って検証するといったような形です。

次にApp.tsxの方で不要になったlocalStorageを使用する部分を削除しておきます。
この部分は参考記事ではこの対応はしていませんが、
localStorageとCookieどちらが使われているかわかりにくくなるかもしれないので念のために消しておきます。

また、この修正でlocalStorageからJWTを読み込まないようにしたので
画面表示時にJWTが表示されることがなくなります。
HttpOnlyなCookieを使ったのでdocument.cookieのようなJavaScriptからは取得できないようになっています。

App.tsx
// 略
// Bearerで送る必要がなくなったので不要
// axios.interceptors.request.use(
//   config => {
//     const { origin } = new URL(config.url as string);
//     const allowedOrigins = [apiUrl];
//     const token = localStorage.getItem('token');
//     if (allowedOrigins.includes(origin)) {
//       config.headers.authorization = `Bearer ${token}`;
//     }
//     return config;
//   },
//   error => {
//     return Promise.reject(error);
//   }
// );

// 略

function App() {
  // localStorageにセットしなくなったので不要
  // const storedJwt = localStorage.getItem('token');
  // 初期値はnullにしている
  const [jwt, setJwt] = useState<string | null>(null);
  // 略
  const getJwt = async () => {
    const { data } = await axios.get(`${apiUrl}/jwt`);
    // localStorageにセットする必要がないので不要
    // localStorage.setItem('token', data.token);
    setJwt(data.token);
  };
// 略

以上でJWTをCookieに保存してサーバーとやりとりできるようになりました。

CSRF対策

localStorageはXSSによる攻撃を受けやすいのに対して、
Cookieの場合はCSRFによる攻撃を受けやすいと言われています。

なのでCookieを使ったtokenのやり取りにはCSRFへの対策とセットで行なう必要があります。

サンプルアプリケーションのアップデート

server.jsにPOSTリクエストを受け付けるエンドポイントを追加します。

server.js
app.post('/foods', (req, res) => {
  foods.push({
    id: foods.length + 1,
    description: 'new food'
  });
  res.json({
    message: 'Food created!'
  });
});

実装は適当ですが、新しくFoodを追加するようなAPIができたイメージですね。
成功した場合はFood created!というメッセージが返ってきます。

また、App.tsxの方から、POSTリクエストを送信するように修正します。

App.tsx
function App() {
  // 略
  const [newFoodMessage, setNewFoodMessage] = useState(null);
  const createFood = async () => {
    try {
      const { data } = await axios.post(`${apiUrl}/foods`);
      setNewFoodMessage(data.message);
      setFetchError(null);
    } catch (err) {
      setFetchError(err.message);
    }
  };

  // 略
  return (
    <>
      // 略
      <section>
        <button onClick={() => createFood()}>
          Create New Food
        </button>
        {newFoodMessage && <p>{newFoodMessage}</p>}
      </section>
    </>
  );
}

CSRFトークンを利用する

expressではcsurfというライブラリを使うことで
手軽にCSRF対策をすることができます。
まずはライブラリを追加します。

docker-compose exec front yarn add csurf

/csrf-tokenにCSRFトークンを取得するエンドポイントを設定します。

server.js
const csrf = require('csurf')
// 略
const csrfProtection = csrf({
  cookie: true
});
app.use(csrfProtection);
app.get('/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

これでCSRFトークンを発行できるようになったので、
App.tsxから利用するようにします。

App.tsx
function App() {
  // 略
  useEffect(() => {
    const getCsrfToken = async () => {
      const { data } = await axios.get(`${apiUrl}/csrf-token`);
      axios.defaults.headers.post['X-CSRF-Token'] = data.csrfToken;
    };
    getCsrfToken();
  }, []);
  // 略
}

画面表示時にCSRFトークンを取得し、axiosに設定するようにしています。
これでCSRFの対策ができました。

※ちなみにCSRFトークン取得時にもCookieの値が検証されるので、403エラーが出る場合はJWT取得後に画面を更新してからCreateしてみてください。今回は一画面にすべて詰め込んでいるのでこんな感じになってしまいます。

最後に

Cookieの仕組みやCORSについての理解があればフロントエンドがReactからVueになろうが
バックエンドがExpressから他のFWになろうが知識を流用できるはずです。

また、localStorageでもCookieでもXSS対策がされていない場合、難易度に差はあれど盗難のリスクが発生するのは同じなので
そもそもXSS対策がされているかどうかのチェックは必須といえるでしょう。

クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか

高い保守性やUXを保持しつつ安全なアプリケーションを目指していきたいですね。

参考

React Authentication: How to Store JWT in a Cookie
クロスサイトでCookieが設定できない場合に確認すること
CORSまとめ
express.jsのcors対応
Express cors middleware
MDN Web Docs Set-Cookie
クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか