メインコンテンツへスキップ
友田 陽大
認証・認可
OAuth2
OIDC
認証
JWT
Next.js
TypeScript
セキュリティ

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

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

公開日
読了時間
25分
著者
友田 陽大
シェア
目次

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

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

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

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

この記事は、IDトークンとアクセストークンの違いを**「丸暗記」ではなく「原理」で**理解し、実装で二度と間違えないことをゴールにします。仕様(OpenID Connect Core 1.0 / RFC 6749 OAuth 2.0 / RFC 9068 JWT Access Token)に忠実に、かつ「どの場面で・どう使うか」を実コード付きで解説します。

この記事の到達点

  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)のこともあります。

# JWTなアクセストークン(例:Auth0でaudienceを指定した場合 / Cognito)
eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9.eyJpc3Mi...

# 不透明なアクセストークン(例:Auth0でaudience未指定 / GitHub)
gho_16C7e42F292c6912E7710c838347Ae178B4a

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

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

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

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

アクセストークンは HTTP の Authorization ヘッダに Bearer スキームで載せます(RFC 6750)。

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

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

3.3 中に入っているもの:scopeaud

JWT形式のアクセストークンをデコードすると、おおむねこういう中身です(RFC 9068 のJWTアクセストークン)。

{
  "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 も使いますが、それは「権限の主体を識別するため」であって「素性を表示するため」ではありません。nameemail のような『身元の情報』は、アクセストークンには載らない(載せるべきでない) のが原則です。それはIDトークンの仕事だからです。


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

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

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

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

{
  "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 を見比べてください。アクセストークンの audAPI でしたが、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を叩く

// ❌ 絶対にやってはいけない
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 誤用②:アクセストークンから個人情報を読む

// ❌ アクセストークンを「身元の情報源」として使っている
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 エンドポイントを叩く。
// ✅ 素性が欲しいときの正攻法: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)。Implicit Flow は非推奨で、アクセストークンをURLフラグメントに載せる古い方式は使いません。

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

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

// 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検証 → トークン交換

// 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 を使います。

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

// 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 を持っているかです。

// 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に送りつけても、audclient_id でありAPI識別子と一致しないため、jwtVerify が例外を投げて401で弾かれます。 第5.1の誤用が、たった一行の audience 指定によって構造的に不可能になる。これが「原理で理解する」ことの実利です。

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


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

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

8.1 SPA(純フロントエンド)の落とし穴

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

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

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

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

// 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アーキテクチャのご相談は、サービスページまたはお問い合わせからどうぞ。同種の認証基盤を本番で運用している木材流通DXの事例も参考にしてください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る