Skip to main content
友田 陽大
Authentication & authorization
OAuth2
OIDC
認証
JWT
Next.js
TypeScript
セキュリティ

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
Reading time
23 min read
Author
友田 陽大
Share

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 / RFC 6749 OAuth 2.0 / RFC 9068 JWT Access Token), 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 tokenAccess token
SpecOpenID Connect (OIDC)OAuth 2.0
In one phraseA 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 itThe client onlyThe API (resource server) only
FormatAlways a JWT (mandated by spec)Undefined (sometimes a JWT, sometimes an opaque string)
ContentsThe 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 displayThe client must not read its contents (opaque premise)
ExpiryShort 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).

# 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).

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).

{
  "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.

{
  "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

// ❌ 絶対にやってはいけない
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

// ❌ アクセストークンを「身元の情報源」として使っている
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.
// ✅ 素性が欲しいときの正攻法: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). 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

// 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

// 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.

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

// 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.

// 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), 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.

// ❌ 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.

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.

// 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.

FormWhere to put the access tokenCaveats
SPAMemory (next-best) / via BFF (recommended)Avoid localStorage (XSS)
BFF / SSRServer-side session + httpOnly CookieDon't hand tokens to the browser (most recommended)
MobileKeychain / KeystorePlaintext 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.

TokenPurposeWho uses itLifetime
ID tokenProof of loginThe clientShort to medium
Access tokenPermission for API accessClient → APIShort-lived (minutes to hours)
Refresh tokenReissue of the access tokenClient → authorization serverLong (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 or contact. The lumber-distribution DX case study, which runs the same kind of authentication foundation in production, is also a useful reference.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading