# ID Token vs. Access Token: The Complete Guide to Not Getting OIDC/OAuth2 Wrong in Implementation

> ID tokens (OpenID Connect) and access tokens (OAuth2) differ in role, destination, and verification method. Conflate them and your API lets authorization slip through — a serious vulnerability. Understand the difference from first principles via 'whom is it addressed to (audience),' and learn the real Authorization Code + PKCE flow, JWT verification with jose, and token storage in a BFF, with production-grade real code.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: OAuth2, OIDC, 認証, JWT, Next.js, TypeScript, セキュリティ
- URL: https://tomodahinata.com/en/blog/id-token-vs-access-token-oidc-oauth2-guide
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- The difference boils down to the destination (aud). An ID token is addressed to the client; an access token to the API. Don't hand a letter to someone it isn't addressed to
- Hitting an API with an ID token is misuse. A state where that passes is proof the API has abandoned aud verification — a hole that allows reusing tokens issued for other apps
- An access token is opaque to the client. Don't read its contents to make authorization decisions; if you want identity, use the ID token or /userinfo
- The recommendation is Authorization Code + PKCE. Hide tokens in the BFF's httpOnly Cookie session; avoid localStorage since it's weak to XSS
- Not mistaking the verification audience is the breakwater. The client matches against client_id, the API against its own API identifier, making ID-token misuse structurally impossible

---

If you're an engineer who has ever implemented authentication, your hand has surely frozen at least once in front of these two tokens.

"Logging in returns both an `id_token` and an `access_token`. Which one do I send to the API?"

Look at sample code on the web, and in one place `id_token` is loaded into the `Authorization: Bearer` header, while in another article `email` is pulled out of `access_token` to identify the user. **Both are implementations you must not ship to production.** These two "common misuses" each lead directly to a different serious vulnerability.

I myself designed and operated, with RS256 JWT verification included, an authentication foundation using AWS Cognito in a B2B subscription SaaS (lumber-distribution DX) that won the Minister of Economy, Trade and Industry Award. Enterprise audits always ask "who verifies the token's `audience`, and where." An implementation that stumbles on that answer loses trust by that alone.

The goal of this article is to understand the difference between ID tokens and access tokens **not by "rote memorization" but by "first principles,"** and to never get it wrong in implementation again. Faithful to the specs ([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)), and explaining "in which scene, how to use them" with real code.

> **What you'll reach in this article**
> 1. Tell the two tokens apart instantly by "whom is it addressed to (audience)"
> 2. Explain why "hitting an API with an ID token" and "reading personal info from an access token" are dangerous
> 3. Write the real Authorization Code + PKCE flow yourself, from token exchange to verification
> 4. Implement **the correct verification for each** on the client side and the API side
> 5. Make the judgment to store tokens safely in SPA / mobile / BFF

---

## 1. Conclusion First: A 30-Second Quick Reference

Before the fine details, here's the answer up front. When in doubt, come back here.

| | **ID token** | **Access token** |
| --- | --- | --- |
| Spec | OpenID Connect (OIDC) | OAuth 2.0 |
| In one phrase | **A certificate of "who logged in"** | **A pass for "what you may access"** |
| Destination (`aud`) | **The client (your app)** | **The resource server (API)** |
| Who may read it | The client only | The API (resource server) only |
| Format | **Always a JWT** (mandated by spec) | **Undefined** (sometimes a JWT, sometimes an opaque string) |
| Contents | The user's identity (`sub`, `name`, `email`, `auth_time`…) | The scope of authority (`scope`, `aud`, `sub`…). Contents not guaranteed |
| May it be sent to the API? | **❌ Absolutely not** | **✅ Send this** |
| May you read its contents for an authorization decision? | OK for the client to read for its own display | **The client must not read its contents** (opaque premise) |
| Expiry | Short to medium (indicates login freshness) | **Short-lived** (minimizes the breach window on leak) |

If you were to remember just one row in this table, it's here.

> **The ID token is "a letter addressed to you (the client)." The access token is "a letter addressed to the API."** You must not hand a letter to someone it isn't addressed to.

Just drawing a single auxiliary line called "destination (audience)" makes most of the confusion vanish. Below, let me dig into why this is so.

---

## 2. Grasping It with an Airport Analogy: Passport and Boarding Pass

Before entering the abstract spec talk, let me build intuition with a familiar model. Picture an airport on an overseas trip.

You hold 2 kinds of "proof." A **passport** and a **boarding pass**.

**Passport = ID token.**
A passport is a document by which a nation guarantees "who you are." It carries a photo, name, nationality, and date of birth. The departure inspector (= your app) looks at it and confirms "yes, this is the person; yes, they passed immigration." But **showing a passport won't get you on the plane.** A passport is "proof of identity," not "permission to board."

**Boarding pass = access token.**
A boarding pass carries "flight number," "seat," "gate," and "boarding time." The gate staff (= API) look at it and decide "you may pass to this seat on this flight." The important thing is that **a boarding pass alone does not guarantee "who you are."** In the extreme, a boarding pass only needs to be machine-readable with a correct face, and a design where it passes the gate even with a blank name field is conceivable. A boarding pass is "permission to access," not "proof of identity."

From here, "why each of the 2 misuses causes an accident" comes into view at once.

- **Showing a passport at the gate (= hitting the API with an ID token)**: the gate staff are handed a document with no flight number or seat, and don't know "which seat to pass this person to." An implementation that lets them through anyway is the same as **abandoning the boarding-pass check**.
- **Verifying identity with a boarding pass (= reading personal info from an access token)**: a boarding pass's name field is not for identity assurance. Trust it for identity verification and you **permit identity spoofing**.

Let me leave the analogy here and step into the technical substance.

---

## 3. Access Token: The "Pass" to the API

The protagonist of OAuth 2.0 is the access token. Its purpose is just one: to represent **"permission for the client to access the API on the user's behalf."**

### 3.1 An Access Token Is, in Principle, "Opaque to the Client"

This is the first pitfall. Many people assume an access token is "a JWT, so I can read its contents," but **per the OAuth2 spec, the format of an access token is not specified.** Depending on the authorization server's implementation, it's sometimes a JWT and sometimes a meaningless random string (opaque token).

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

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

So the iron rule on the client side is this.

> **The client never interprets the contents of an access token.** Treat it as an opaque parcel that you just carry "as-is" to the API.

Break this, and your app breaks the moment the authorization server changes the token format in the future. A JWT turning into an opaque string, the structure of the `scope` claim changing — such changes are the authorization server's free discretion, and the client must not depend on the contents on its own.

### 3.2 There's Only One Way to Send It to the API

The access token is loaded into the HTTP `Authorization` header with the `Bearer` scheme ([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
  },
});
```

The name `Bearer` (payable to the bearer) gets at the essence. **Whoever holds this token can exercise its authority.** That's exactly why a leak is fatal, and why defenses like "make it short-lived," "don't put it in the URL," and "hide it in an httpOnly Cookie" are needed, as discussed later.

### 3.3 What's Inside: `scope` and `aud`

Decode a JWT-format access token, and the contents are roughly like this (the JWT access token of [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068)).

```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
}
```

What the API side should really look at is `aud` (is it addressed to me?) and `scope` (what is it permitted to do?). It uses `sub` too, but that's "to identify the subject of authority," not "to display identity." **"Identity information" like `name` or `email` is not (and should not be) carried in an access token** as a rule. That's the ID token's job.

---

## 4. ID Token: The "Certificate of Identity" to the Client

The ID token is a concept that OpenID Connect (OIDC) added to OAuth2. OAuth2 was originally a mechanism for "authorization," and had no standard for handling "authentication = who is this user." OIDC fills that hole, and its deliverable is the ID token.

### 4.1 The ID Token Conveys "the Fact That a Login Happened" to the Client

The ID token's destination (`aud`) is **the client itself**. It's "a certificate of user login, issued for your app." So its contents are composed of claims about the user's identity.

```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"
}
```

Compare the `aud`. The access token's `aud` was the **API**, but the ID token's `aud` is the **client (`client_id`)**. Two tokens born from the same login of the same user have **different destinations**. This is the source of all the differences.

### 4.2 The ID Token Is "Always" a JWT

The access token's format was undefined, but **the ID token is mandated to be a JWT by the OIDC spec.** And its use is clear: it's for "the client to put the user into a logged-in state and display the name and avatar on screen."

Let me draw an important boundary line here.

> It is **correct for the client to read the ID token's contents for its own display / session establishment**.
> But it is **wrong to send it to the API and use it for authorization**.

Because the ID token is "a misaddressed letter for the API." In the next chapter, let me look concretely at why this misuse becomes a vulnerability.

### 4.3 `nonce`: Preventing ID-Token Replay

`nonce` is an OIDC-specific defense. At login start, the client generates a random `nonce` and passes it to the authorization server, then checks that the returned ID token's `nonce` matches. This prevents the "reuse a stolen old ID token (replay)" attack. The access token has no such mechanism, because the conception is fundamentally different between "proof of identity" and "permission to access."

---

## 5. The Two Big Misuses, and the Accidents They Lead To

This is the heart of the article. Let me dissect, with attack scenarios, the 2 misuses touched on at the start.

### 5.1 Misuse ①: Hitting an API with an ID Token

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

What happens? Many immature API implementations let it through by looking only at the received JWT's signature and expiry. Then the following problems cascade.

- **`aud` verification gets gutted**: an ID token's `aud` is the client ID. If the API side were verifying "is `aud` my API identifier," the ID token should be rejected. If it's not rejected, that means **the API is not verifying `aud`**. This signifies the serious hole that "a token issued for another app passes too." An attacker can log in with a different app (a client they manage) and hit your API with the obtained token.
- **No `scope` exists**: an ID token has no `scope`. Fine-grained authorization like "read-only permission" or "only a specific resource" is impossible, and the API becomes all-or-nothing.
- **Token bloat**: because an ID token carries `name`, `email`, `picture`, etc., you carry a needlessly large header on every request.

In other words, a state of "an ID token passes through the API" is **proof that the API isn't doing authorization properly.** As in the analogy, a gate that lets a passport with no flight number through is abandoning boarding confirmation in the first place.

### 5.2 Misuse ②: Reading Personal Info from an Access Token

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

This has 2 problems.

1. **It may not be readable in the first place**: as seen in 3.1, an access token may be an opaque string. The moment you try to `atob`-decode it, it throws. "It works because it's a JWT in my environment" is a castle on sand that collapses with a single config change on the authorization server.
2. **Even if readable, you must not trust it**: an access token's contents are not "for the client." The authorization server can change the access token's claim composition without notice. The client depending on it is a breach of contract.

So, when you want the user's identity, what do you do? There are 2 answers.

- **If you already hold an ID token, read from it.** (`name`, `email` are ID-token claims)
- **If you want the latest identity, use the access token to hit the `/userinfo` endpoint.**

```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, ... }
```

Notice that the use of the access token here is completely correct. `/userinfo` is a "user-identity API" the authorization server provides, and the access token functions as a pass to that API. **It's not "reading the access token as an identity source," but "using the access token to call the identity API."** It looks like a one-character difference, but the design philosophy is the polar opposite.

---

## 6. Implementation: Receiving Both Tokens Correctly with Authorization Code + PKCE

Once the theory is clear, let's move our hands. As of 2026, the recommended standard for browser apps, mobile, and server-side web alike is **Authorization Code Flow + PKCE** ([OAuth 2.0 Security BCP / RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)). The Implicit Flow is deprecated, and the old scheme of loading an access token into the URL fragment is not used.

Here I take a **BFF (Backend for Frontend) setup**. Not placing tokens where the browser's JavaScript can touch them, but hiding them in a server-side httpOnly Cookie session — because this is currently the least accident-prone setup (the reason is in Chapter 8). The stack is exemplified with Next.js (App Router) Route Handlers.

### 6.1 Login Start: Plant PKCE and 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;
}
```

The `audience` parameter is the branch point of this flow. Many authorization servers, Auth0 among them, issue "a JWT access token addressed to that API" when you specify `audience`, and return "an opaque access token usable only at `/userinfo`" when you don't. **"If you want an access token to hit the API, always specify `audience`"** — the 3.1 talk directly connects to practice here.

### 6.2 Callback: state Verification → Token Exchange

```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);
}
```

Notice that the external response is touched only after Zod-verifying it with `TokenResponse.parse()`. **The system boundary (the authorization server's response) is not trusted. Beat it into shape with types to guarantee its form, then pass it inward.** This is a universal principle not limited to authentication.

---

## 7. Verification: Confirm the Two Tokens "Each Correctly"

Once you receive a token, verify the signature, issuer, destination, and expiry. **Checking `aud` correctly here is the breakwater that structurally prevents the accidents of Chapter 5.** For JWT verification, use the standard [`jose`](https://github.com/panva/jose).

### 7.1 Client Side: Verify the ID Token and Establish a Session

```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, ... } を信頼してセッションに使える
}
```

The point is the single line `audience: OIDC_CLIENT_ID`. **An ID token's destination is the client**, so always match here against your own `client_id`. This repels the "be fed an ID token meant for another client" attack.

### 7.2 API Side: Verify the Access Token and Authorize

The API side does an entirely different verification. **Is the destination (`aud`) my API identifier**, and **does it hold the required `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);
}
```

That the value passed to `audience` is **different** on the client side (7.1) and the API side (7.2) tells the whole story of this article.

- The client verifies the ID token with `audience = client_id`
- The API verifies the access token with `audience = the API identifier`

And **if someone sends an ID token to this API, because its `aud` is `client_id` and doesn't match the API identifier, `jwtVerify` throws and it's rejected with 401.** The misuse of §5.1 becomes structurally impossible thanks to a single line of `audience` specification. This is the practical payoff of "understanding by first principles."

> **In the case of an opaque access token**: in a setup using opaque tokens without specifying `audience`, the API side does **introspection ([RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662))**, not JWT verification, querying the authorization server. It sends the token to `POST /introspect` and judges from the received `{ active: true, scope, aud, ... }`. There's a trade-off — local verification (JWT) is fast with zero network round-trips, while introspection is strong against immediate revocation. For a high-frequency API, JWT + short-lived; if immediate revocation is a requirement, introspection — choose by requirement.

---

## 8. "Where to Put" Tokens: SPA / Mobile / BFF

Even if you receive and verify correctly, **get the storage location wrong and it leaks in one shot.** Let me organize by app form.

### 8.1 The Pitfall of SPA (Pure Front End)

Implementations that put the access token in `localStorage` flood the world, but this is weak to XSS (cross-site scripting). Any script can read `localStorage`, so a single XSS strips out the token wholesale.

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

If you absolutely must put it in the browser in a pure SPA, the next-best is to hold only a short-lived access token in memory (a JS variable) and leave refresh to the authorization server's **Refresh Token Rotation** (single-use + auto-reissue + theft detection). Even so, XSS resistance is limited, and the 2026 best practice is the BFF below.

### 8.2 Recommended: BFF + httpOnly Cookie

This is the setup taken in Chapter 6. Store the tokens in a **server-side session**, and hand the browser only a **session Cookie** with the `httpOnly`, `Secure`, and `SameSite` attributes.

```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;
}
```

When the browser front end hits the API, it **goes through its own BFF**. The BFF takes the access token out of the session and attaches it to the API request. This way, **the browser's JavaScript never touches the access token even once.** The XSS blast radius shrinks dramatically. It's also consistent with Chapter 5's "don't put tokens in the URL" principle.

### 8.3 Mobile (Native)

On iOS/Android native, store tokens in the OS's secure storage (iOS Keychain / Android Keystore-backed EncryptedSharedPreferences). Plaintext storage like `AsyncStorage` is not acceptable. The principles — access tokens short-lived, refresh tokens with mandatory Rotation — are common.

| Form | Where to put the access token | Caveats |
| --- | --- | --- |
| SPA | Memory (next-best) / via BFF (recommended) | Avoid `localStorage` (XSS) |
| BFF / SSR | Server-side session + httpOnly Cookie | Don't hand tokens to the browser (most recommended) |
| Mobile | Keychain / Keystore | Plaintext storage forbidden, Rotation mandatory |

---

## 9. While We're At It: Refresh Tokens and the "Third One" Confusion

In addition to ID tokens and access tokens, the `refresh_token` (refresh token) tends to cause confusion, so let me fix its positioning here.

| Token | Purpose | Who uses it | Lifetime |
| --- | --- | --- | --- |
| ID token | Proof of login | The client | Short to medium |
| Access token | Permission for API access | Client → API | **Short-lived** (minutes to hours) |
| Refresh token | Reissue of the access token | Client → authorization server | Long (Rotation premise) |

A refresh token is a "voucher" for getting a new access token without re-login when the access token expires. Because it's long-lived, the leak risk is high, so **Refresh Token Rotation** (swap for a new one on each use, immediately revoking the old; if the same refresh token is used twice, treat it as theft and revoke the whole lineage) is the modern standard. A refresh token, naturally, is **not sent to the API.** Its destination is only the authorization server's token endpoint.

"The 3 tokens each have a different destination" — return to this single point and you'll never again be lost as to which one to send where.

---

## 10. Pre-Production Checklist

These are the items I actually check when reviewing an implementation. Copy and use them.

**Client side**

- [ ] Is what you send to the API the `access_token` (you're not sending the `id_token`)?
- [ ] Are you verifying the ID token with `jwtVerify`, passing your own `client_id` to `audience`?
- [ ] Are you matching `nonce` to prevent replay?
- [ ] Are you **not making authorization decisions by reading** the access token's contents (keeping the opaque premise)?
- [ ] When you want identity, are you using the ID token or `/userinfo`, not reading the access token?
- [ ] Are you preventing CSRF with `state` / using PKCE (S256)?

**API (resource server) side**

- [ ] Are you verifying the access token by passing **your own API identifier** to `audience` (is the ID token rejected)?
- [ ] Are you verifying `iss` (issuer), the signature, and `exp`?
- [ ] Are you enforcing least privilege with `scope` (separating authentication and authorization)?
- [ ] If immediate revocation is a requirement, have you prepared introspection or short-lived tokens + a revocation list?

**Storage**

- [ ] Are you not putting tokens in `localStorage` (have you considered BFF + httpOnly Cookie)?
- [ ] Are access tokens short-lived / refresh tokens Rotation-enabled?
- [ ] Are you not leaving tokens in URLs, logs, or the Referer?

Each line of this checklist corresponds to an "accident" seen in Chapter 5. If even one is ❌, that's a reservation for a future incident.

---

## 11. Summary: The Difference Boils Down to "Destination"

I wrote at length, but the essence returns to the first line.

> **The ID token is addressed to the client, the access token to the API.** Divide the destination (`aud`) correctly at issuance, and match it correctly at verification. That alone structurally prevents most authentication-related accidents.

- **ID token**: OIDC's deliverable. A certificate that conveys "who logged in" to the client. Always a JWT. `aud = client_id`. Don't send it to the API.
- **Access token**: OAuth2's protagonist. A pass that conveys "what you may access" to the API. Format undefined, the client doesn't read the contents. `aud = API`. Send this to the API.
- **Not mistaking the verification `audience`** is the breakwater that makes ID-token misuse impossible.

Authentication is one of the few domains where "just works" doesn't cut it. A wrong implementation that "just works" is precisely what's dangerous, and an API that skips `aud` verification keeps working without incident right up until the day it's attacked. What an enterprise ultimately asks an external developer, pushed to the limit, is **"can I leave this to you without an accident."** Handling each token's destination correctly, one by one — that is the quietest, most certain answer to that question.

---

> The code in this article is a reconstruction based on the specs (OIDC Core / RFC 6749 / RFC 9068). For consultation on designing authentication foundations with AWS Cognito / Auth0 / a self-built OIDC provider, enterprise SSO (SAML/OIDC) integration, or BFF architecture, please reach out via the [services page](/services) or [contact](/contact). The [lumber-distribution DX case study](/case-studies/lumber-industry-dx), which runs the same kind of authentication foundation in production, is also a useful reference.
