# IDトークンとアクセストークンの違い：OIDC/OAuth2を「実装で間違えない」ための完全ガイド

> IDトークン（OpenID Connect）とアクセストークン（OAuth2）は役割も宛先も検証方法も別物です。混同するとAPIが認可を素通しする重大な脆弱性になります。両者の違いを『誰に向けたものか（audience）』から原理で理解し、Authorization Code + PKCEの実フロー、joseによるJWT検証、BFFでのトークン保管まで本番運用に耐える実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: OAuth2, OIDC, 認証, JWT, Next.js, TypeScript, セキュリティ
- URL: https://tomodahinata.com/blog/id-token-vs-access-token-oidc-oauth2-guide

## 要点

- 違いは宛先（aud）に集約される。IDトークンはクライアント宛て、アクセストークンはAPI宛て。宛先の違う手紙を相手に渡さない
- IDトークンでAPIを叩くのは誤用。それが通る状態はAPIがaud検証を放棄している証拠で、他アプリ向けトークンの流用を許す穴になる
- アクセストークンはクライアントにとって不透明。中身を読んで認可判断せず、素性が欲しければIDトークンか/userinfoを使う
- 推奨はAuthorization Code + PKCE。トークンはBFFのhttpOnly Cookieセッションに隠し、localStorageはXSSに弱いので避ける
- 検証のaudienceを取り違えないことが防波堤。クライアントはclient_id、APIは自分のAPI識別子で照合し、IDトークン誤用を構造的に不可能にする

---

認証を実装したことのあるエンジニアなら、一度はこの2つのトークンの前で手が止まったはずです。

「ログインすると `id_token` と `access_token` の両方が返ってくる。どっちをAPIに送ればいいんだ？」

ネット上のサンプルコードを見ると、片方では `id_token` を `Authorization: Bearer` ヘッダに載せ、別の記事では `access_token` から `email` を取り出してユーザーを特定しています。**どちらも、本番に出してはいけない実装です。** この2つの「よくある誤用」は、それぞれ別の重大な脆弱性に直結します。

私自身、経済産業大臣賞を受賞したB2BサブスクリプションSaaS（木材流通DX）で、AWS Cognitoを使った認証基盤をRS256のJWT検証込みで設計・運用しました。エンタープライズの監査では「トークンの `audience` を誰が・どこで検証しているか」を必ず問われます。そこで答えに詰まる実装は、それだけで信用を失います。

この記事は、IDトークンとアクセストークンの違いを**「丸暗記」ではなく「原理」で**理解し、実装で二度と間違えないことをゴールにします。仕様（[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) / [RFC 6749 OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) / [RFC 9068 JWT Access Token](https://datatracker.ietf.org/doc/html/rfc9068)）に忠実に、かつ「どの場面で・どう使うか」を実コード付きで解説します。

> **この記事の到達点**
> 1. 2つのトークンを「誰に向けたものか（audience）」で一発で見分けられる
> 2. 「IDトークンでAPIを叩く」「アクセストークンから個人情報を読む」がなぜ危険か説明できる
> 3. Authorization Code + PKCE の実フローを、トークン交換から検証まで自分で書ける
> 4. クライアント側・API側で**それぞれ正しい検証**を実装できる
> 5. SPA / モバイル / BFF で、トークンを安全に保管する判断ができる

---

## 1. 結論を先に：30秒で分かる早見表

細かい話に入る前に、答えを先に置きます。迷ったらここに戻ってください。

| | **IDトークン** | **アクセストークン** |
| --- | --- | --- |
| 仕様 | OpenID Connect (OIDC) | OAuth 2.0 |
| ひとことで言うと | **「誰がログインしたか」の証明書** | **「何にアクセスしてよいか」の通行許可証** |
| 宛先（`aud`） | **クライアント（あなたのアプリ）** | **リソースサーバ（API）** |
| 読んでよいのは誰か | クライアントだけ | API（リソースサーバ）だけ |
| 形式 | **必ずJWT**（仕様で規定） | **不定**（JWTのことも、不透明文字列のことも） |
| 中身 | ユーザーの素性（`sub`, `name`, `email`, `auth_time`…） | 権限の範囲（`scope`, `aud`, `sub`…）。中身は保証されない |
| APIに送ってよいか | **❌ 絶対にダメ** | **✅ これを送る** |
| 中身を見て認可判断してよいか | クライアントが自分の表示のために見るのはOK | **クライアントは中身を見てはいけない**（不透明な前提） |
| 有効期限 | 短〜中（ログインの鮮度を示す） | **短命**（漏洩時の被害窓を最小化） |

この表の中で、たった一行だけ覚えるとしたらここです。

> **IDトークンは「あなた（クライアント）宛ての手紙」。アクセストークンは「API宛ての手紙」。** 宛先の違う手紙を相手に渡してはいけない。

「宛先（audience）」という補助線を一本引くだけで、ほとんどの混乱は消えます。以下、なぜそうなるのかを掘り下げます。

---

## 2. 空港のたとえで掴む：パスポートと搭乗券

抽象的な仕様の話に入る前に、身近なモデルで直感を作ります。海外旅行の空港を思い浮かべてください。

あなたは2種類の「証明」を持っています。**パスポート**と**搭乗券**です。

**パスポート = IDトークン。**
パスポートは「あなたが誰であるか」を国家が保証する書類です。顔写真、氏名、国籍、生年月日が載っています。出国審査官（=あなたのアプリ）はこれを見て「確かに本人だ、確かに入国管理を通った」と確認します。でも、**パスポートを見せても飛行機には乗れません。** パスポートは「身元の証明」であって「搭乗の許可」ではないからです。

**搭乗券 = アクセストークン。**
搭乗券には「便名」「座席」「ゲート」「搭乗時刻」が書かれています。ゲートのスタッフ（=API）はこれを見て「この便のこの座席に通してよい」と判断します。重要なのは、**搭乗券だけでは『あなたが誰か』を保証しない**ことです。極端な話、搭乗券は券面さえ正しければ機械が読み取れればよく、氏名欄が空でもゲートは通れる設計すらありえます。搭乗券は「アクセスの許可」であって「身元の証明」ではないのです。

ここから、2つの誤用が「なぜ事故るのか」が一気に見えてきます。

- **パスポートをゲートに見せる（= IDトークンでAPIを叩く）**：ゲートのスタッフは便名も座席も書かれていない書類を渡され、「この人をどの席に通せばいいのか」分からない。それでも通してしまう実装は、**搭乗券のチェックを放棄している**のと同じ。
- **搭乗券で本人確認する（= アクセストークンから個人情報を読む）**：搭乗券の氏名欄は身元保証のためのものではない。そこを信じて本人確認すると、**身元の偽装を許す**。

たとえはここまでにして、技術的な実体に踏み込みます。

---

## 3. アクセストークン：APIへの「通行許可証」

OAuth 2.0 の主役はアクセストークンです。目的はただ一つ、**「クライアントがユーザーに代わってAPIへアクセスする許可」**を表すことです。

### 3.1 アクセストークンは「クライアントにとって不透明」が原則

ここが最初の落とし穴です。多くの人がアクセストークンを「JWTだから中身を読める」と思い込んでいますが、**OAuth2の仕様上、アクセストークンの形式は規定されていません。** 認可サーバの実装次第で、JWTのこともあれば、意味を持たないランダム文字列（不透明トークン / opaque token）のこともあります。

```text
# JWTなアクセストークン（例：Auth0でaudienceを指定した場合 / Cognito）
eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9.eyJpc3Mi...

# 不透明なアクセストークン（例：Auth0でaudience未指定 / GitHub）
gho_16C7e42F292c6912E7710c838347Ae178B4a
```

だからクライアント側の鉄則はこうです。

> **アクセストークンの中身を、クライアントは絶対に解釈しない。** ただAPIへ「そのまま」運ぶだけの不透明な荷物として扱う。

これを破ると、認可サーバが将来トークン形式を変えた瞬間にアプリが壊れます。JWTだったものが不透明文字列に変わる、`scope` クレームの構造が変わる——こうした変更は認可サーバ側の自由裁量であり、クライアントが勝手に中身に依存してはいけない契約です。

### 3.2 APIへの送り方は1つだけ

アクセストークンは HTTP の `Authorization` ヘッダに `Bearer` スキームで載せます（[RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750)）。

```ts
const res = await fetch("https://api.example.com/v1/invoices", {
  headers: {
    Authorization: `Bearer ${accessToken}`, // ← 送るのは access_token
  },
});
```

`Bearer`（持参人払い）という名前が本質を言い当てています。**このトークンを持っている者は、誰であれその権限を行使できる。** だからこそ漏洩が致命的で、後述するように「短命にする」「URLに載せない」「httpOnly Cookieに隠す」といった防御が要るわけです。

### 3.3 中に入っているもの：`scope` と `aud`

JWT形式のアクセストークンをデコードすると、おおむねこういう中身です（[RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) のJWTアクセストークン）。

```jsonc
{
  "iss": "https://auth.example.com/",   // 発行者
  "sub": "auth0|6712ab...",             // ユーザーの一意ID（不変）
  "aud": "https://api.example.com",     // ★宛先 = このAPIのために発行された
  "scope": "read:invoices write:invoices", // ★許可された操作の範囲
  "exp": 1750000000,                    // 失効時刻（短命）
  "iat": 1749996400
}
```

API側が本当に見るべきは `aud`（自分宛てか）と `scope`（何を許可されているか）です。`sub` も使いますが、それは「権限の主体を識別するため」であって「素性を表示するため」ではありません。**`name` や `email` のような『身元の情報』は、アクセストークンには載らない（載せるべきでない）** のが原則です。それはIDトークンの仕事だからです。

---

## 4. IDトークン：クライアントへの「身元の証明書」

IDトークンは OpenID Connect (OIDC) が OAuth2 に追加した概念です。OAuth2 はもともと「認可（authorization）」のための仕組みで、「認証（authentication）= このユーザーは誰か」を扱う標準を持っていませんでした。その穴を埋めるのがOIDCであり、その成果物がIDトークンです。

### 4.1 IDトークンは「ログインが起きた事実」をクライアントに伝える

IDトークンの宛先（`aud`）は**クライアント自身**です。「あなたのアプリのために発行された、ユーザーログインの証明書」です。だから中身はユーザーの素性に関するクレームで構成されます。

```jsonc
{
  "iss": "https://auth.example.com/",   // 発行者
  "sub": "auth0|6712ab...",             // ユーザーの一意ID（access_tokenのsubと一致）
  "aud": "myapp-spa-client-id",         // ★宛先 = あなたのアプリ（client_id）
  "exp": 1750000000,
  "iat": 1749996400,
  "auth_time": 1749996390,              // 実際に認証が行われた時刻
  "nonce": "n-0S6_WzA2Mj",              // ★リプレイ攻撃対策（後述）
  "name": "山田 太郎",
  "email": "taro@example.com",
  "email_verified": true,
  "picture": "https://.../avatar.png"
}
```

`aud` を見比べてください。アクセストークンの `aud` は **API** でしたが、IDトークンの `aud` は **クライアント（`client_id`）** です。同じユーザーの、同じログインから生まれた2枚のトークンが、**それぞれ違う宛先**を持っている。これがすべての違いの源泉です。

### 4.2 IDトークンは「必ず」JWT

アクセストークンは形式不定でしたが、**IDトークンはOIDC仕様でJWTであることが定められています。** そして用途は明確で、「クライアントがユーザーをログイン状態にし、画面に名前やアバターを表示する」ためのものです。

ここで重要な境界線を引きます。

> IDトークンの中身を**クライアントが自分の表示・セッション確立のために読むのは正しい**。
> しかしそれを**APIに送って認可に使うのは間違い**。

なぜなら、IDトークンは「APIにとって宛先違いの手紙」だからです。次の章で、この誤用がなぜ脆弱性になるのかを具体的に見ます。

### 4.3 `nonce`：IDトークンのリプレイを防ぐ

`nonce` はOIDC特有の防御です。ログイン開始時にクライアントがランダムな `nonce` を生成して認可サーバに渡し、返ってきたIDトークンの `nonce` が一致するかを確認します。これにより「盗んだ古いIDトークンを使い回す（リプレイ）」攻撃を防ぎます。アクセストークンにはこの仕組みはありません。発想が「身元の証明」と「アクセスの許可」で根本的に違うからです。

---

## 5. 2大誤用と、その先で起きる事故

ここがこの記事の核心です。冒頭で触れた2つの誤用を、攻撃シナリオ付きで解剖します。

### 5.1 誤用①：IDトークンでAPIを叩く

```ts
// ❌ 絶対にやってはいけない
const res = await fetch("https://api.example.com/v1/invoices", {
  headers: { Authorization: `Bearer ${idToken}` }, // id_tokenを送っている
});
```

何が起きるか。多くの未成熟なAPI実装は、受け取ったJWTの署名と有効期限だけを見て通してしまいます。すると、次の問題が連鎖します。

- **`aud` 検証が骨抜きになる**：IDトークンの `aud` はクライアントIDです。API側がもし「`aud` が自分のAPI識別子か」を検証していれば、IDトークンは弾かれるはず。弾かれないなら、**APIは `aud` を検証していない**ということ。これは「他のアプリ向けに発行されたトークンでも通る」という重大な穴を意味します。攻撃者が別アプリ（自分が管理するクライアント）でログインして得たトークンで、あなたのAPIを叩けてしまう。
- **`scope` が存在しない**：IDトークンには `scope` がありません。「読み取りだけ許可」「特定リソースだけ許可」という細かい認可ができず、APIは all-or-nothing になります。
- **トークン肥大化**：IDトークンには `name`, `email`, `picture` などが載るため、毎リクエストで無駄に大きいヘッダを運ぶことになります。

つまり「IDトークンでAPIが通ってしまう」状態は、**APIが認可をまともにやっていない証拠**なのです。たとえの通り、便名の書いていないパスポートでゲートを通せてしまうゲートは、そもそも搭乗確認を放棄しています。

### 5.2 誤用②：アクセストークンから個人情報を読む

```ts
// ❌ アクセストークンを「身元の情報源」として使っている
const payload = JSON.parse(atob(accessToken.split(".")[1]));
const userEmail = payload.email; // ← そもそもemailが無いかもしれない
showProfile(userEmail);
```

これには2つの問題があります。

1. **そもそも読めない可能性がある**：3.1で見た通り、アクセストークンは不透明文字列かもしれません。`atob` でデコードしようとした瞬間に例外を吐きます。「自分の環境ではJWTだから動く」は、認可サーバの設定変更ひとつで崩れる砂上の楼閣です。
2. **読めても信じてはいけない**：アクセストークンの中身は「クライアントのためのもの」ではありません。認可サーバはアクセストークンのクレーム構成を予告なく変えられます。クライアントがそこに依存するのは契約違反です。

では、ユーザーの素性が欲しいときはどうするか。答えは2つです。

- **すでにIDトークンを持っているなら、そこから読む。**（`name`, `email` はIDトークンのクレーム）
- **最新の素性が欲しいなら、アクセストークンを使って `/userinfo` エンドポイントを叩く。**

```ts
// ✅ 素性が欲しいときの正攻法：UserInfoエンドポイント
// アクセストークンは「APIへの通行許可証」として使い、その許可で素性APIを叩く
const res = await fetch("https://auth.example.com/userinfo", {
  headers: { Authorization: `Bearer ${accessToken}` },
});
const profile = await res.json(); // { sub, name, email, ... }
```

ここでのアクセストークンの使い方は完全に正しいことに注目してください。`/userinfo` は認可サーバが提供する「ユーザー素性API」であり、アクセストークンはそのAPIへの通行許可証として機能しています。**「アクセストークンを身元の情報源として読む」のではなく、「アクセストークンを使って身元APIを呼ぶ」**。一文字違いに見えて、設計思想は正反対です。

---

## 6. 実装：Authorization Code + PKCE で両トークンを正しく受け取る

理屈が分かったら、手を動かします。2026年現在、ブラウザアプリ・モバイル・サーバサイドWebのいずれでも推奨される標準は **Authorization Code Flow + PKCE** です（[OAuth 2.0 Security BCP / RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)）。Implicit Flow は非推奨で、アクセストークンをURLフラグメントに載せる古い方式は使いません。

ここでは **BFF（Backend for Frontend）構成**を採ります。トークンをブラウザのJavaScriptから触れる場所に置かず、サーバ側のhttpOnly Cookieセッションに隠す——これが現在もっとも事故りにくい構成だからです（理由は第8章）。スタックは Next.js (App Router) の Route Handler を例にします。

### 6.1 ログイン開始：PKCE と state / nonce を仕込む

```ts
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "node:crypto";

const base64url = (buf: Buffer) =>
  buf.toString("base64url"); // Node 16+ は base64url を直接サポート

export async function GET() {
  // PKCE: verifier は秘密、challenge は公開（S256ハッシュ）
  const codeVerifier = base64url(randomBytes(32));
  const codeChallenge = base64url(
    createHash("sha256").update(codeVerifier).digest(),
  );
  // CSRF対策の state と、IDトークンのリプレイ対策の nonce
  const state = base64url(randomBytes(16));
  const nonce = base64url(randomBytes(16));

  const authUrl = new URL(`${process.env.OIDC_ISSUER}/authorize`);
  authUrl.search = new URLSearchParams({
    response_type: "code",
    client_id: process.env.OIDC_CLIENT_ID!,
    redirect_uri: process.env.OIDC_REDIRECT_URI!,
    // openid を入れるとIDトークンが、audience指定でAPI向けaccess_tokenが返る
    scope: "openid profile email read:invoices",
    audience: "https://api.example.com", // ★これでAPI向けJWTアクセストークンになる
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  }).toString();

  const res = NextResponse.redirect(authUrl);
  // verifier / state / nonce は httpOnly Cookie に短命で保持（ブラウザJSから不可視）
  const secure = { httpOnly: true, secure: true, sameSite: "lax" as const, maxAge: 600, path: "/" };
  res.cookies.set("pkce_verifier", codeVerifier, secure);
  res.cookies.set("oidc_state", state, secure);
  res.cookies.set("oidc_nonce", nonce, secure);
  return res;
}
```

`audience` パラメータがこのフローの分岐点です。Auth0をはじめ多くの認可サーバは、`audience` を指定すると「そのAPI宛てのJWTアクセストークン」を発行し、指定しないと「`/userinfo` だけに使える不透明アクセストークン」を返します。**「APIを叩くためのアクセストークンが欲しいなら `audience` を必ず指定する」**——3.1の話がここで実務に直結します。

### 6.2 コールバック：state検証 → トークン交換

```ts
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { cookies } from "next/headers";

// 認可サーバの応答は「システム境界」。必ずZodで形を検証してから使う
const TokenResponse = z.object({
  access_token: z.string().min(1),
  id_token: z.string().min(1),
  refresh_token: z.string().optional(),
  token_type: z.literal("Bearer"),
  expires_in: z.number().int().positive(),
});

export async function GET(req: NextRequest) {
  const url = new URL(req.url);
  const code = url.searchParams.get("code");
  const returnedState = url.searchParams.get("state");

  const jar = await cookies();
  const expectedState = jar.get("oidc_state")?.value;
  const codeVerifier = jar.get("pkce_verifier")?.value;

  // CSRF対策：開始時に発行した state と一致しなければ拒否
  if (!code || !returnedState || returnedState !== expectedState || !codeVerifier) {
    return NextResponse.json({ error: "invalid_request" }, { status: 400 });
  }

  // 認可コード → トークン交換（confidential client なので client_secret も送る）
  const tokenRes = await fetch(`${process.env.OIDC_ISSUER}/oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.OIDC_REDIRECT_URI!,
      client_id: process.env.OIDC_CLIENT_ID!,
      client_secret: process.env.OIDC_CLIENT_SECRET!,
      code_verifier: codeVerifier, // ★PKCE：verifierを提示してコードを交換
    }),
  });
  if (!tokenRes.ok) {
    return NextResponse.json({ error: "token_exchange_failed" }, { status: 502 });
  }

  const tokens = TokenResponse.parse(await tokenRes.json());
  // → 次章でIDトークンを検証し、セッションを確立する
  return await establishSession(tokens, jar.get("oidc_nonce")?.value);
}
```

外部応答を `TokenResponse.parse()` でZod検証してから触っている点に注目してください。**システム境界（認可サーバの応答）は信頼しない。型で殴って形を保証してから内部へ通す。** これは認証に限らない普遍原則です。

---

## 7. 検証：2つのトークンを「それぞれ正しく」確かめる

トークンを受け取ったら、署名・発行者・宛先・期限を検証します。**ここで `aud` を正しくチェックすることが、第5章の事故を構造的に防ぐ防波堤**になります。JWT検証には標準的な [`jose`](https://github.com/panva/jose) を使います。

### 7.1 クライアント側：IDトークンを検証してセッションを張る

```ts
// lib/auth/verify-id-token.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

// JWKSは公開鍵の集合。createRemoteJWKSet はキャッシュ＋キーローテーション追従つき
const JWKS = createRemoteJWKSet(
  new URL(`${process.env.OIDC_ISSUER}/.well-known/jwks.json`),
);

export async function verifyIdToken(idToken: string, expectedNonce?: string): Promise<JWTPayload> {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: process.env.OIDC_ISSUER,        // iss が発行者と一致するか
    audience: process.env.OIDC_CLIENT_ID,   // ★IDトークンの aud = client_id
    // jose は exp / iat / nbf を自動検証。許容クロックスキューも指定可
    clockTolerance: "5s",
  });

  // nonce はjoseの標準検証外。リプレイ対策として自分で突き合わせる
  if (expectedNonce && payload.nonce !== expectedNonce) {
    throw new Error("nonce mismatch: possible replay attack");
  }
  return payload; // { sub, name, email, ... } を信頼してセッションに使える
}
```

ポイントは `audience: OIDC_CLIENT_ID` の一行です。**IDトークンの宛先はクライアント**なので、ここを必ず自分の `client_id` で照合します。これにより「他クライアント向けのIDトークンを食わされる」攻撃を弾けます。

### 7.2 API側：アクセストークンを検証して認可する

API側はまったく別の検証をします。**宛先（`aud`）が自分のAPI識別子か**、そして**必要な `scope` を持っているか**です。

```ts
// app/api/(protected)/invoices/route.ts  ← リソースサーバ側
import { NextRequest, NextResponse } from "next/server";
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL(`${process.env.OIDC_ISSUER}/.well-known/jwks.json`),
);
const API_AUDIENCE = "https://api.example.com"; // ★このAPIの識別子

async function authorize(req: NextRequest, requiredScope: string) {
  const header = req.headers.get("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return { ok: false as const, status: 401 };

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: process.env.OIDC_ISSUER,
      audience: API_AUDIENCE, // ★アクセストークンの aud = このAPI。IDトークンはここで弾かれる
      clockTolerance: "5s",
    });
    // scope は空白区切り文字列。必要な権限を含むか確認（最小権限の原則）
    const scopes = String(payload.scope ?? "").split(" ").filter(Boolean);
    if (!scopes.includes(requiredScope)) {
      return { ok: false as const, status: 403 }; // 認証は通るが認可は足りない
    }
    return { ok: true as const, sub: payload.sub as string, scopes };
  } catch {
    return { ok: false as const, status: 401 }; // 署名・aud・期限のいずれかが不正
  }
}

export async function GET(req: NextRequest) {
  const auth = await authorize(req, "read:invoices");
  if (!auth.ok) return new NextResponse(null, { status: auth.status });

  const invoices = await loadInvoicesForUser(auth.sub);
  return NextResponse.json(invoices);
}
```

クライアント側（7.1）とAPI側（7.2）で `audience` に渡す値が**違う**ことが、この記事のすべてを物語っています。

- クライアントは `audience = client_id` でIDトークンを検証する
- APIは `audience = API識別子` でアクセストークンを検証する

そして**もし誰かがIDトークンをこのAPIに送りつけても、`aud` が `client_id` でありAPI識別子と一致しないため、`jwtVerify` が例外を投げて401で弾かれます。** 第5.1の誤用が、たった一行の `audience` 指定によって構造的に不可能になる。これが「原理で理解する」ことの実利です。

> **不透明アクセストークンの場合**：`audience` を指定せず不透明トークンを使う構成では、API側はJWT検証ではなく**イントロスペクション（[RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662)）**で認可サーバに問い合わせます。`POST /introspect` にトークンを送り、`{ active: true, scope, aud, ... }` を受け取って判断します。ローカル検証（JWT）はネットワーク往復ゼロで速く、イントロスペクションは即時失効に強い——というトレードオフがあります。高頻度APIならJWT＋短命、即時失効が要件ならイントロスペクション、と要件で選びます。

---

## 8. トークンを「どこに置くか」：SPA / モバイル / BFF

正しく受け取り検証できても、**保管場所**を誤れば一発で漏洩します。アプリ形態ごとに整理します。

### 8.1 SPA（純フロントエンド）の落とし穴

`localStorage` にアクセストークンを置く実装が世にあふれていますが、これはXSS（クロスサイトスクリプティング）に弱い。任意のスクリプトが `localStorage` を読めるため、XSSが1つでもあればトークンを根こそぎ盗まれます。

```ts
// ❌ localStorage はXSSでトークンが抜かれる
localStorage.setItem("access_token", accessToken);
```

純SPAでどうしてもブラウザに置くなら、メモリ（JS変数）に短命のアクセストークンだけ保持し、リフレッシュは認可サーバの **Refresh Token Rotation**（使い捨て＋自動再発行＋盗難検知）に委ねるのが次善です。それでもXSS耐性は限定的で、2026年のベストプラクティスは次のBFFです。

### 8.2 推奨：BFF + httpOnly Cookie

第6章で採った構成です。トークンは**サーバ側のセッション**に保管し、ブラウザには `httpOnly`・`Secure`・`SameSite` 属性付きの**セッションCookie**だけを渡します。

```ts
// lib/auth/establish-session.ts（第6章の establishSession の実体）
import { NextResponse } from "next/server";
import { verifyIdToken } from "./verify-id-token";

export async function establishSession(tokens: TokenSet, expectedNonce?: string) {
  // IDトークンを検証してから素性を取り出す（信頼の起点）
  const claims = await verifyIdToken(tokens.id_token, expectedNonce);

  // トークン本体はサーバ側ストア（Redis等）に保存し、ブラウザには渡さない
  const sessionId = await sessionStore.create({
    sub: claims.sub,
    accessToken: tokens.access_token,   // ★ブラウザのJSからは到達不能
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  });

  const res = NextResponse.redirect(new URL("/dashboard", process.env.APP_ORIGIN));
  res.cookies.set("sid", sessionId, {
    httpOnly: true,   // JSから読めない＝XSSで盗めない
    secure: true,     // HTTPSのみ
    sameSite: "lax",  // CSRF緩和
    path: "/",
    maxAge: 60 * 60 * 8,
  });
  // 一時Cookieは破棄
  for (const c of ["pkce_verifier", "oidc_state", "oidc_nonce"]) res.cookies.delete(c);
  return res;
}
```

ブラウザのフロントエンドがAPIを叩くときは、**自分のBFFを経由**します。BFFがセッションからアクセストークンを取り出してAPIへ付け替える。こうすると、**ブラウザのJavaScriptはアクセストークンに一度も触れません。** XSSの被害面が劇的に縮みます。第5章の「URLにトークンを載せない」原則とも一貫しています。

### 8.3 モバイル（ネイティブ）

iOS/Androidネイティブでは、トークンをOSのセキュアストレージ（iOS Keychain / Android Keystore-backed EncryptedSharedPreferences）に保存します。`AsyncStorage` のような平文ストレージは不可。アクセストークンは短命、リフレッシュトークンはRotation必須、という原則は共通です。

| 形態 | アクセストークンの置き場所 | 注意点 |
| --- | --- | --- |
| SPA | メモリ（次善）／BFF経由（推奨） | `localStorage`は避ける（XSS） |
| BFF / SSR | サーバ側セッション + httpOnly Cookie | ブラウザにトークンを渡さない（最推奨） |
| モバイル | Keychain / Keystore | 平文ストレージ禁止・Rotation必須 |

---

## 9. ついでに整理：リフレッシュトークンと「3枚目」の混乱

IDトークン・アクセストークンに加えて、`refresh_token`（リフレッシュトークン）が混乱の原因になりがちなので、ここで位置づけを固めます。

| トークン | 目的 | 誰が使う | 寿命 |
| --- | --- | --- | --- |
| IDトークン | ログインの証明 | クライアント | 短〜中 |
| アクセストークン | APIアクセスの許可 | クライアント→API | **短命**（分〜時間） |
| リフレッシュトークン | アクセストークンの再発行 | クライアント→認可サーバ | 長（Rotation前提） |

リフレッシュトークンは「アクセストークンが切れたとき、再ログインさせずに新しいアクセストークンをもらう」ための引換券です。長命なぶん漏洩リスクが高いので、**Refresh Token Rotation**（1回使うごとに新しいものへ差し替え、古いものは即失効。同じリフレッシュトークンが二度使われたら盗難とみなして系統ごと失効）が現代の標準です。リフレッシュトークンも当然、**APIには送りません。** 宛先は認可サーバのトークンエンドポイントだけです。

「3枚のトークンはそれぞれ宛先が違う」——この一点に立ち返れば、どれをどこに送るかで迷うことはなくなります。

---

## 10. 本番投入前チェックリスト

実装をレビューするとき、私が実際に確認している項目です。コピーして使ってください。

**クライアント側**

- [ ] APIに送っているのは `access_token` か（`id_token` を送っていないか）
- [ ] IDトークンを `jwtVerify` で検証し、`audience` に自分の `client_id` を渡しているか
- [ ] `nonce` を突き合わせてリプレイを防いでいるか
- [ ] アクセストークンの中身を**読んで認可判断していない**か（不透明前提を守る）
- [ ] 素性が欲しいときはIDトークンか `/userinfo` を使い、アクセストークンを読んでいないか
- [ ] `state` でCSRFを防いでいるか／PKCE（S256）を使っているか

**API（リソースサーバ）側**

- [ ] `audience` に**自分のAPI識別子**を渡してアクセストークンを検証しているか（IDトークンが弾かれるか）
- [ ] `iss`（発行者）・署名・`exp` を検証しているか
- [ ] `scope` で最小権限を強制しているか（認証と認可を分けているか）
- [ ] 即時失効が要件なら、イントロスペクションか短命トークン＋失効リストを用意したか

**保管**

- [ ] トークンを `localStorage` に置いていないか（BFF + httpOnly Cookie を検討したか）
- [ ] アクセストークンは短命か／リフレッシュトークンはRotation済みか
- [ ] トークンをURL・ログ・Refererに残していないか

このチェックリストの一行ごとに、第5章で見た「事故」が対応しています。1つでも❌があれば、それは将来のインシデントの予約です。

---

## 11. まとめ：違いは「宛先」に集約される

長く書きましたが、本質は最初の一行に戻ります。

> **IDトークンはクライアント宛て、アクセストークンはAPI宛て。** 宛先（`aud`）を発行時に正しく分け、検証時に正しく照合する。それだけで、認証まわりのほとんどの事故は構造的に防げる。

- **IDトークン**：OIDCの成果物。「誰がログインしたか」をクライアントに伝える証明書。必ずJWT。`aud = client_id`。APIには送らない。
- **アクセストークン**：OAuth2の主役。「何にアクセスしてよいか」をAPIに伝える通行許可証。形式は不定でクライアントは中身を見ない。`aud = API`。これをAPIに送る。
- **検証の `audience` を取り違えないこと**が、IDトークン誤用を不可能にする防波堤になる。

認証は「動けばいい」で済まない数少ない領域です。動いてしまう間違った実装こそが危険で、`aud` 検証を省いたAPIは、攻撃を受けるその日まで何事もなく動き続けます。エンタープライズが外部の開発者に最終的に問うのは、突き詰めると**「任せて事故が起きないか」**です。トークンの宛先を一つひとつ正しく扱えること——それがこの問いへの、最も静かで確実な答えになります。

---

> 本記事のコードは仕様（OIDC Core / RFC 6749 / RFC 9068）に基づく再構成です。AWS Cognito・Auth0・自作OIDCプロバイダによる認証基盤の設計、エンタープライズSSO（SAML/OIDC）統合、BFFアーキテクチャのご相談は、[サービスページ](/services)または[お問い合わせ](/contact)からどうぞ。同種の認証基盤を本番で運用している[木材流通DXの事例](/case-studies/lumber-industry-dx)も参考にしてください。
