認証を実装したことのあるエンジニアなら、一度はこの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 / RFC 6749 OAuth 2.0 / RFC 9068 JWT Access Token)に忠実に、かつ「どの場面で・どう使うか」を実コード付きで解説します。
この記事の到達点
- 2つのトークンを「誰に向けたものか(audience)」で一発で見分けられる
- 「IDトークンでAPIを叩く」「アクセストークンから個人情報を読む」がなぜ危険か説明できる
- Authorization Code + PKCE の実フローを、トークン交換から検証まで自分で書ける
- クライアント側・API側でそれぞれ正しい検証を実装できる
- 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 中に入っているもの:scope と aud
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 も使いますが、それは「権限の主体を識別するため」であって「素性を表示するため」ではありません。name や email のような『身元の情報』は、アクセストークンには載らない(載せるべきでない) のが原則です。それは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 を見比べてください。アクセストークンの 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を叩く
// ❌ 絶対にやってはいけない
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つの問題があります。
- そもそも読めない可能性がある:3.1で見た通り、アクセストークンは不透明文字列かもしれません。
atobでデコードしようとした瞬間に例外を吐きます。「自分の環境ではJWTだから動く」は、認可サーバの設定変更ひとつで崩れる砂上の楼閣です。 - 読めても信じてはいけない:アクセストークンの中身は「クライアントのためのもの」ではありません。認可サーバはアクセストークンのクレーム構成を予告なく変えられます。クライアントがそこに依存するのは契約違反です。
では、ユーザーの素性が欲しいときはどうするか。答えは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に送りつけても、aud が client_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です。
8.2 推奨:BFF + httpOnly Cookie
第6章で採った構成です。トークンはサーバ側のセッションに保管し、ブラウザには httpOnly・Secure・SameSite 属性付きのセッション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の事例も参考にしてください。