【初学者向け】【Next.js】環境変数の意図せぬ漏洩を防ぐために知っておくべきこと

こちらは日本 CTO 協会 24 卒 Advent Calendar の記事です🎉

今日は「**クライアント側の環境変数は外部から丸見えなので気をつけよう**」という内容です。

フロントエンド、バックエンドそれぞれで環境変数を扱ったことがある方にとっては、常識的な内容かもしれません。しかし普段 Next.js でフロント開発を行っている私は、恥ずかしながら最近までこれを理解できていませんでした。

今回は私自身の備忘録も兼ねて、特に理解が難しい（と思われる）Next.jsを例にとって、初学者向けにこのポイントを解説します。

## 記事の概要
Web アプリ開発では、「環境変数」という仕組みを使うことで、API キーなどの機密情報を秘匿しつつ、環境ごとに値を切り替えることができます。
しかし実は、これをフロントエンドで利用した場合、その値は秘匿されずに、ブラウザ上のソースコードや通信内容から簡単に確認されてしまうのです。

このような問題は特に、 Next.js のように「サーバー環境」と「クライアント環境」の両方を併せ持つフレームワークでややこしいです。

Next.js では同一の env ファイルで定義した環境変数がサーバー側からもクライアント側からも参照可能な一方で、その参照方法（ `NEXT_PUBLIC_`の有無）によって実際に値が埋め込まれる実行環境が異なります。
この仕組みは非常に便利な一方で、どの値がどこで公開されるのかを正しく理解しないと、意図せず情報漏洩してしまうことに繋がりかねません。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/3dae9fa0-c909-f02b-6554-c89946a48134.png" width="450px" />

本記事ではそんな Next.js を例に、クライアント側で環境変数を使用すると何が起こるのかと、それを回避するための方法について解説します。
それでは見ていきましょう！

なお注意点は以下です。
- 初学者向けに内容やコードは一部簡略化しています
- サンプルコードには App Router を使用しています

## 対象読者

- クライアント側の環境変数が外部から見えることを知らなかった方
- Next.js の環境変数の扱いに不安がある方

## `NEXT_PUBLIC_` とは

`NEXT_PUBLIC_` は「クライアントに公開しても良い環境変数」を定義するための Next.js の仕組みです。
`NEXT_PUBLIC_` が付いた環境変数は、ビルド時にクライアント用の JavaScript コードへ埋め込まれます。値は JavaScript の一部としてユーザーのブラウザへ配信されるため、クライアント側でも利用できると同時に、ユーザーからも閲覧ができてしまうという特徴があります。
（もし今は分からなくても、記事を通して解説するので大丈夫です！）

## クライアント側で環境変数を使ってみる（問題となる例）

今回は、API キーという秘匿すべき情報をわざと `NEXT_PUBLIC_` をつけてクライアントから参照し、なぜ問題なのかを確認してみます。（実際には API キーは秘匿すべき情報で、クライアント側で使うべきではありません。）

#### `.env.local` ファイル

ルート直下に `.env.local` を作成し、API キーを以下のように定義します。

```plaintext
NEXT_PUBLIC_API_KEY="sample-api-key"
```

#### クライアントコンポーネントから参照

以下は [JSONPlaceholder](https://jsonplaceholder.typicode.com/) の API を利用してユーザーを取得するコンポーネントです。[^1]
`process.env.NEXT_PUBLIC_API_KEY` のようにして環境変数を参照します。

```tsx
"use client";
import { User } from "@/types/user";
import { useState } from "react";

export const SampleClientComponent = () => {
  const [user, setUser] = useState<User>();

  const handleClick = async () => {
    const res = await fetch("https://jsonplaceholder.typicode.com/users/1", {
      headers: {
        "API-Key": process.env.NEXT_PUBLIC_API_KEY || "",
      },
    });
    const user: User = await res.json();
    setUser(user);
  };

  return (
    <div>
      <p>ユーザー名: {user?.name}</p>
      <button onClick={handleClick}>ユーザー取得</button>
    </div>
  );
};
```

`next dev`で、ローカルの開発サーバーを立ち上げて確認してみます。

![スクリーンショット 2024-12-14 23.34.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/47f8aa0d-584f-86cf-c7f4-d3e5b78ae10f.png)
ボタン押下でリクエストが走り、正常にユーザーが取得できました。

一見正常に動いているようですが、しかし、実はここで問題が発生しています。

## 問題点

`NEXT_PUBLIC_API_KEY` が、ブラウザ上で自由に閲覧可能な状態になってしまっています。

### 実際に確認する

実際に開発者ツールで確認してみると、以下の 2 箇所から値が漏洩していることがわかります。

1. 「Network」タブ
リクエストのヘッダーを見てみると「Api-Key: sample-api-key」と表示されています。リクエストを投げたユーザーなら誰でも、この値を簡単に見ることができてしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/4002fef2-d6f2-0fed-90ca-0fe2bcf92ec7.png" width="400px" />


2. 「Source」タブ
top ディレクトリの上で右クリックをし、「Search in all file」で `sample-api-key` を検索します。すると `_next/static/chuncks`配下のファイルでヒットしてしまいました。
この JavaScript は、React をビルドした結果生成されたコードです。つまり、このページを訪れた全ユーザーが、API キーを含んだ JavaScript コードを取得できてしまうのです。[^2]

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/cfe66198-57e6-9181-9342-782f40f46325.png" width="400px" />

### なぜこれが問題なのか？

API キーは、リクエストをする際の認証情報なので、本来秘密にすべき情報です。
このキーが漏洩すると

- 不正利用による追加のコスト発生
- 秘密情報へのアクセス

のようなセキュリティリスクがあります。

これらのリスクを防ぎつつリクエストを送るには、どうすればよいでしょうか？

## サーバー側で環境変数を使って問題を解決する

結論、サーバー側で API キーを使うように修正すれば問題は解決します。
サーバー内の通信やソースコードは、ユーザーのデバイスからはアクセスすることができないため、API キーが外部に漏れることが無いためです。

まずは修正方針から詳しく見ていきます。

### 修正方針

「サーバー側で API キーを使うように修正すれば問題は解決」とありますが、具体的にどう修正するのでしょうか。

現在、API キーは「API へのリクエスト処理の中」で利用されています。

```tsx
const res = await fetch("https://jsonplaceholder.typicode.com/users/1", {
  headers: {
    "API-Key": process.env.NEXT_PUBLIC_API_KEY || "", // ここで利用
  },
});
```

この処理をクライアント側でなく、サーバー側で行うようにすれば良さそうです。

そのためには、JSONPlaceholder へのリクエストを、クライアントから直接行うのではなく、間にサーバーを経由させるように修正します。

![Frame 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/39cad670-013a-f66e-5acb-315477c27611.png)
↑修正のイメージ図



### コード修正例

上記方針に沿って、コードを修正します。

#### クライアントコンポーネント

```tsx
"use client";
import { User } from "@/types/user";
import { useState } from "react";

export const SampleClientComponent = () => {
  const [user, setUser] = useState<User>();

  const handleClick = async () => {
    // 直接 JSONPlaceholder ではなく、自前のサーバーエンドポイントへ
    const res = await fetch("/api/user");
    const user: User = await res.json();
    setUser(user);
  };

  return (
    <div>
      <p>ユーザー名: {user?.name}</p>
      <button onClick={handleClick}>ユーザー取得</button>
    </div>
  );
};
```

#### サーバー側

`/app/api/user/route.ts` を新規作成し、以下の処理を書きます。[^3]
ここでは API へのリクエスト行い、レスポンスは JSON へ変換して、そのままま受け流しているだけのようです。

```ts
import { NextResponse } from "next/server";

export async function GET() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users/1", {
    headers: {
      // サーバー側では `NEXT_PUBLIC_`不要
      "API-Key": process.env.API_KEY || "",
    },
  });
  const data = await res.json();
  return NextResponse.json(data);
}
```

`NEXT_PUBLIC_`は不要になったので `.env.local` も修正します。

```plaintext
API_KEY="sample-api-key"
```

### 動作確認

再度ローカルの開発サーバーを立ち上げて動作を確認してみます。
こちらも無事にユーザーが取得できていますね。

![スクリーンショット 2024-12-14 23.34.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/93514665-2d38-260e-a12a-84e640765254.png)

環境変数が秘匿できているかを確認してみます。

先ほどと同様の操作で Network タブや Source タブで検索してみても、今回は `sample-api-key` の値は見つかりません。
サーバー側でのみ API キーを扱うようになり、クライアント側にはキーを埋め込んでいないため、外部から値を盗み見ることができなくなりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/30ff74eb-53d1-5c06-2417-57718e7e5677.png" width="300px" />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/f42bb777-6e88-d5a6-0f98-3b19488f4e84.png" width="300px" />

## 図で比較してみる

図で比較して整理してみます。

### 修正前
- サーバーからクライアントへ、 API キーが埋め込まれた JavaScript が配信される
- クライアントは API キーを使って、外部API へ直接リクエストを送る

![Frame 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/b0be7ec7-7e47-6921-c300-e6aa841912d6.png)

### 修正後
- サーバーからクライアントへ、秘匿情報が入っていない JavaScript が配信される
- クライアントは 外部API とのやり取りに、必ずサーバーを経由する
- API キーは、外部API と直接やり取りをするサーバーだけが所有している

![Frame 3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2547823/87d6beb2-2efe-4d0a-8963-bd69914e3d09.png)

このように、秘匿したい値をサーバー側で用いるよう修正すると、クライアントに API キーを渡す必要が無くなり、セキュリティの問題を解決できました。

## じゃあ `NEXT_PUBLIC_` を付ける環境変数は何がある？

クライアント側で見られても問題ない値なら、 `NEXT_PUBLIC_` をつけても OK です。例えば、

- 公開可能な API の URL（認証不要のエンドポイントなど）
- サイト名、UI 上で表示するメッセージ

など、漏洩してもセキュリティリスクのないものは定義できます。

逆に、秘匿すべき情報（API キー、DB 認証情報など）は絶対に `NEXT_PUBLIC_` を付けないでください。
もし誤って付けられていた場合は、本記事で紹介した方法で値が漏れていないかや、不正利用がされていないかを確認し、早急に値を変更する対応が必要になります。

## まとめ

記事の内容をまとめると、以下の3つです。
- クライアント側の環境変数はブラウザに露出してしまう
- 秘匿情報はサーバー側からのみ参照し、クライアントには渡さないようにする
- 公開しても問題ない値のみ NEXT*PUBLIC* を付けるようにする

今回は、Next.js の中でも最も分かりやすそうな Route Handler を使う解決方法を例示しました。Next.js には他にも Server Component や getServerSideProps(Pages Router) など、サーバー環境で実行されるコードがいくつかあります。
環境変数を扱う際は秘匿すべき値かどうかを確認し、もし秘匿すべきであればサーバーからのみ参照するようにしましょう。

## 補足
内容がざっくりと理解できた方は、[公式ドキュメント](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables)を読んでみるのをオススメします。本記事で触れていない内容も紹介されています。（ex.開発環境と本番環境での値の切り替え方など）

有志の方が[日本語訳してくださっているサイト](https://nextjs-ja-translation-docs.vercel.app/docs/basic-features/environment-variables)もあり、英語に抵抗がある方はこちらもオススメです。(ただし2年ほど前から更新されていないようなので、参考程度にしてください)

[^1]: 本来 JSONPlaceholder は認証無しで利用できますが、今回は便宜上あえて API キーを埋め込んでいます。任意のヘッダを追加しても JSONPlaceholder 側で無視されるので、問題は無いようです。

[^2]: 本番ビルドした際のコードにも、同様に API キーが埋め込まれます。`next build → next start`を実行するとローカルPCでも本番ビルド後のコードを確認できます。

[^3]: Next.js の Route Handler という機能を使って API サーバーを作成しています。ドキュメントは[こちら](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)