「OAuth 2.0 と OpenID Connect の違いがあいまい」「Authorization Code Flow と PKCE は結局どう書くのか」「Google ログイン・GitHub ログインを Node.js でゼロから実装したい」「JWT を返してきた ID Token は信用していいのか」「Implicit と Password はもう使ってはいけないのか」。OAuth 2.0 / OIDC は仕様書(RFC 6749, RFC 8252, OpenID Connect Core 1.0)が膨大で、現場のコードに落とすところで多くの人がつまずきます。本記事は Node.js 22 + Express 5 + TypeScript 5 + jose 5 を前提に、40 個超のコピペで動くコードで「Authorization Code Flow + PKCE」「Google / GitHub / X(Twitter)/ Apple 連携」「ID Token 検証」「Refresh / Introspection / Revocation」まで、迷いどころをすべて潰します。Express 5、REST API 設計と合わせて読めば、認証認可レイヤーは完成します。
- OAuth 2.0 と OpenID Connect の 本質的な違い(認可 vs 認証)
- 4 種のフローのうち 2026 年に使ってよいもの(Auth Code + PKCE / Client Credentials)
- Express 5 + TypeScript で書く Google / GitHub / X / Apple ログインのフルコード
- PKCE(code_verifier / code_challenge)、state、nonce の 正しい実装と検証
- ID Token の JWKs 取得・kid・alg 検証(jose ライブラリ)
- Refresh Token・Token Introspection(RFC 7662)・Token Revocation(RFC 7009)
- Auth.js(NextAuth v5)・iron-session・Lucia Auth・Clerk / Auth0 / WorkOS の使い分け
- OWASP OAuth ベストプラクティス・リダイレクト URI 設計・PKCE が必須化された背景
- 1. OAuth 2.0 と OpenID Connect:5 分で正しく理解する
- 2. 4 つのフローと「2026 年に使ってよいもの」
- 3. Authorization Code Flow + PKCE をゼロから実装
- 4. Google OAuth(OIDC)を Express で実装する
- 5. ID Token(JWT)を jose で安全に検証する
- 6. GitHub OAuth を実装する(OAuth 2.0 のみ・ID Token なし)
- 7. X(Twitter)OAuth 2.0 と Apple Sign-In
- 8. Refresh Token・Introspection・Revocation
- 9. Bearer Token を API サーバーで検証する(Resource Server 側)
- 10. Client Credentials(サーバー間 OAuth)
- 11. openid-client で書き直す(現場の定番ライブラリ)
- 12. Auth.js(NextAuth v5)で OAuth を Next.js に乗せる
- 13. iron-session・Lucia・マネージド IdP の使い分け
- 14. セキュリティ・OWASP・ベストプラクティス
- 15. SAML との比較・関連仕様
- 16. テスト・運用・ハマりどころ
- 17. プライバシー・スコープ設計・運用観点
- 18. まとめと次に学ぶこと
1. OAuth 2.0 と OpenID Connect:5 分で正しく理解する
1.1 OAuth 2.0 は「認可」、OpenID Connect は「認証」
最大の誤解は「OAuth = ログインの仕組み」と思うことです。OAuth 2.0(RFC 6749)は 認可(Authorization) の仕様、つまり「リソースへのアクセス権限の委譲」を扱います。一方 OpenID Connect(OIDC) は OAuth 2.0 の上に乗せた認証(Authentication)レイヤーで、「誰がログインしているか」を ID Token(JWT)で受け渡します。「Google でログイン」のような体験は厳密には OIDC を使っています。
1.2 4 つの登場人物(ロール)
- Resource Owner:エンドユーザー(あなた)
- Client:あなたのアプリ(SPA、モバイル、Web サーバー)
- Authorization Server:Google・GitHub・Auth0 など、トークンを発行する側
- Resource Server:API サーバー(Gmail API、GitHub API、自前 API)
1.3 フロー全体像(Authorization Code Flow + PKCE)
+----------+ +---------------+
| | --(1) 認可リクエスト ----------------> | |
| | (client_id, redirect_uri, | Authorization|
| | scope, state, code_challenge) | Server |
| | | (Google等) |
| Client | |
| | (code, code_verifier, client_id) |
| | Resource Server
+----------+
1.4 環境準備(本記事のスタック)
# Node.js 22 LTS / TypeScript 5.6 / Express 5
node -v
# v22.11.0
mkdir oauth-handson && cd oauth-handson
npm init -y
npm i express@5 express-session cookie-parser cors helmet zod
npm i jose openid-client jsonwebtoken
npm i -D typescript @types/node @types/express @types/express-session
@types/cookie-parser tsx vitest
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext
--esModuleInterop --strict --outDir dist
1.5 package.json の scripts
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run"
}
}
1.6 .env(本番ではシークレットマネージャへ)
# 共通
SESSION_SECRET=please-change-me-32bytes-or-more
APP_BASE_URL=http://localhost:3000
# Google
GOOGLE_CLIENT_ID=xxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxx
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
# GitHub
GITHUB_CLIENT_ID=Iv1.xxxxxx
GITHUB_CLIENT_SECRET=xxxxxx
GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback
2. 4 つのフローと「2026 年に使ってよいもの」
2.1 4 種類のフロー一覧
| フロー | 用途 | 2026 推奨 |
|---|---|---|
| Authorization Code + PKCE | Web / SPA / モバイル | ○ 標準 |
| Client Credentials | サーバー間 / M2M | ○ 標準 |
| Implicit | 旧 SPA 向け | × 廃止(OAuth 2.1) |
| Resource Owner Password | パスワード直接渡し | × 廃止(OAuth 2.1) |
| Device Code | TV / CLI | ○ 特殊用途 |
2.2 OAuth 2.1 ドラフトの主な変更
- Implicit Flow と Password Flow を 正式廃止
- Authorization Code Flow に PKCE を必須化(Public / Confidential 問わず)
- redirect_uri は完全一致のみ(部分一致禁止)
- Bearer Token の URI クエリ送信を非推奨化
2.3 なぜ Implicit Flow は廃止されたのか
Implicit はリダイレクトのフラグメントに access_token を直接乗せるため、ブラウザ履歴・リファラ・ログから漏れやすい欠陥がありました。SPA で「サーバーがないから」と Implicit を選ぶ時代は終わり、PKCE 付き Authorization Code Flow を SPA からも直接実行するのが現代の正解です。
3. Authorization Code Flow + PKCE をゼロから実装
3.1 PKCE(Proof Key for Code Exchange)とは
PKCE(ピクシー、RFC 7636)は 認可コード横取り攻撃を防ぐ仕組みです。クライアントは毎回ランダムな code_verifier を生成し、その SHA-256 ハッシュ(code_challenge)を /authorize に送ります。/token に進む際、元の code_verifier を提示することで「認可コードを取得した本人」であることを証明します。
3.2 code_verifier / code_challenge の生成(TypeScript)
// src/pkce.ts
import { randomBytes, createHash } from "node:crypto";
/** 43-128文字の[A-Z a-z 0-9 - . _ ~]のランダム文字列 */
export function generateCodeVerifier(length = 64): string {
return randomBytes(length)
.toString("base64url")
.replace(/[^A-Za-z0-9-._~]/g, "")
.slice(0, length);
}
/** SHA-256(verifier) を base64url した文字列 */
export function generateCodeChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
/** state / nonce 用の128bitランダム */
export function generateRandomState(): string {
return randomBytes(16).toString("base64url");
}
3.3 state と nonce の役割
- state:CSRF 防御。リダイレクト先で同じ値を検証
- nonce:OIDC 限定。リプレイ攻撃防御。ID Token に同じ値が入る
- code_verifier:PKCE 用。認可コード横取り防御
3.4 セッション設定(express-session)
// src/session.ts
import session from "express-session";
import type { RequestHandler } from "express";
declare module "express-session" {
interface SessionData {
state?: string;
nonce?: string;
codeVerifier?: string;
provider?: string;
user?: { sub: string; email?: string; name?: string };
tokens?: {
access_token: string;
refresh_token?: string;
id_token?: string;
expires_at: number;
};
}
}
export const sessionMiddleware: RequestHandler = session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 8,
},
});
3.5 Express サーバー初期化
// src/server.ts
import express from "express";
import helmet from "helmet";
import cookieParser from "cookie-parser";
import { sessionMiddleware } from "./session.js";
import { googleRouter } from "./providers/google.js";
import { githubRouter } from "./providers/github.js";
const app = express();
app.use(helmet());
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(sessionMiddleware);
app.use("/auth/google", googleRouter);
app.use("/auth/github", githubRouter);
app.get("/", (req, res) => {
if (req.session.user) {
res.send(`Hello, ${req.session.user.name ?? req.session.user.sub}
logout`);
} else {
res.send(`Login with Google
Login with GitHub`);
}
});
app.get("/auth/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});
app.listen(3000, () => console.log("http://localhost:3000"));
4. Google OAuth(OIDC)を Express で実装する
4.1 Google Cloud Console での設定
- GCP コンソール → APIs & Services → OAuth consent screen を「External」で作成
- Credentials → Create credentials → OAuth client ID → Web application
- Authorized redirect URIs に
http://localhost:3000/auth/google/callbackを 完全一致で追加 - 表示された Client ID / Client Secret を .env に保存
4.2 Google の OIDC エンドポイント
// src/providers/google-endpoints.ts
// OIDC Discovery: https://accounts.google.com/.well-known/openid-configuration
export const GOOGLE = {
authorization: "https://accounts.google.com/o/oauth2/v2/auth",
token: "https://oauth2.googleapis.com/token",
userinfo: "https://openidconnect.googleapis.com/v1/userinfo",
jwks: "https://www.googleapis.com/oauth2/v3/certs",
issuer: "https://accounts.google.com",
revocation: "https://oauth2.googleapis.com/revoke",
} as const;
4.3 /auth/google/login(認可リクエスト)
// src/providers/google.ts
import { Router } from "express";
import { GOOGLE } from "./google-endpoints.js";
import {
generateCodeVerifier,
generateCodeChallenge,
generateRandomState,
} from "../pkce.js";
export const googleRouter = Router();
googleRouter.get("/login", (req, res) => {
const state = generateRandomState();
const nonce = generateRandomState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
req.session.state = state;
req.session.nonce = nonce;
req.session.codeVerifier = codeVerifier;
req.session.provider = "google";
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
response_type: "code",
scope: "openid email profile",
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
access_type: "offline", // refresh_token を得るため
prompt: "consent", // 同意画面を毎回出すと refresh_token が出やすい
});
res.redirect(`${GOOGLE.authorization}?${params.toString()}`);
});
4.4 /auth/google/callback(トークン交換)
// src/providers/google.ts (続き)
import { verifyIdToken } from "../jwt-verify.js";
googleRouter.get("/callback", async (req, res) => {
const { code, state, error } = req.query as Record;
if (error) return res.status(400).send(`OAuth error: ${error}`);
if (!code) return res.status(400).send("no code");
if (!state || state !== req.session.state) {
return res.status(400).send("invalid state");
}
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) return res.status(400).send("no code_verifier");
// /token への POST(application/x-www-form-urlencoded)
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
code_verifier: codeVerifier,
});
const tokenRes = await fetch(GOOGLE.token, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
if (!tokenRes.ok) {
const t = await tokenRes.text();
return res.status(400).send(`token error: ${t}`);
}
const tokens = (await tokenRes.json()) as {
access_token: string;
expires_in: number;
refresh_token?: string;
id_token: string;
scope: string;
token_type: "Bearer";
};
// ID Token を検証して subject を取り出す
const claims = await verifyIdToken(tokens.id_token, {
issuer: GOOGLE.issuer,
audience: process.env.GOOGLE_CLIENT_ID!,
jwksUri: GOOGLE.jwks,
nonce: req.session.nonce!,
});
req.session.user = {
sub: claims.sub as string,
email: claims.email as string | undefined,
name: claims.name as string | undefined,
};
req.session.tokens = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
id_token: tokens.id_token,
expires_at: Date.now() + tokens.expires_in * 1000,
};
// 使い終わったワンタイム値を消す
delete req.session.state;
delete req.session.nonce;
delete req.session.codeVerifier;
res.redirect("/");
});
4.5 UserInfo エンドポイントを呼ぶ
// src/providers/google.ts (続き)
googleRouter.get("/me", async (req, res) => {
const token = req.session.tokens?.access_token;
if (!token) return res.status(401).send("login first");
const r = await fetch(GOOGLE.userinfo, {
headers: { authorization: `Bearer ${token}` },
});
if (!r.ok) return res.status(r.status).send(await r.text());
res.json(await r.json());
});
5. ID Token(JWT)を jose で安全に検証する
5.1 なぜ ID Token の検証が必要か
ID Token は JWT で署名されていますが、署名検証・iss・aud・exp・nonce のすべてを確認しないと、別アプリ向けに発行されたトークンを取り違える事故が発生します。jsonwebtoken の jwt.decode() だけで終わらせるのは絶対に避けてください(署名未検証のため)。
5.2 JWKs を取得して鍵を回す(jose)
// src/jwt-verify.ts
import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
type VerifyOpts = {
issuer: string;
audience: string;
jwksUri: string;
nonce?: string;
};
const jwksCache = new Map<string, ReturnType>();
function getJWKS(uri: string) {
let jwks = jwksCache.get(uri);
if (!jwks) {
jwks = createRemoteJWKSet(new URL(uri), {
cacheMaxAge: 10 * 60 * 1000, // 10分キャッシュ
cooldownDuration: 30 * 1000,
});
jwksCache.set(uri, jwks);
}
return jwks;
}
export async function verifyIdToken(
idToken: string,
opts: VerifyOpts,
): Promise {
const { payload } = await jwtVerify(idToken, getJWKS(opts.jwksUri), {
issuer: opts.issuer,
audience: opts.audience,
algorithms: ["RS256", "ES256"], // alg混同攻撃防止のためホワイトリスト
});
// nonce のチェックは jose が自動でやらないので自前で
if (opts.nonce && payload.nonce !== opts.nonce) {
throw new Error("nonce mismatch");
}
return payload;
}
5.3 alg 混同攻撃(none / HS256 すり替え)を防ぐ
jose の algorithms オプションでホワイトリスト指定すれば、攻撃者が alg: none や対称鍵 HS256 にすり替えた JWT を弾けます。絶対に algorithms を省略しないでください。
5.4 ID Token の主要クレーム
// ID Token (decoded payload example)
{
"iss": "https://accounts.google.com",
"azp": "xxx.apps.googleusercontent.com",
"aud": "xxx.apps.googleusercontent.com",
"sub": "111111111111111111111",
"email": "user@example.com",
"email_verified": true,
"at_hash": "abc...",
"name": "Yamada Taro",
"picture": "https://lh3.googleusercontent.com/...",
"given_name": "Taro",
"family_name": "Yamada",
"iat": 1727500000,
"exp": 1727503600,
"nonce": "the-nonce-we-sent"
}
6. GitHub OAuth を実装する(OAuth 2.0 のみ・ID Token なし)
6.1 GitHub と Google の違い
GitHub の OAuth Apps は OAuth 2.0 のみに対応し、OIDC ではないため ID Token は返りません。ユーザー情報は /user API を Bearer Token で叩いて取得します。
6.2 GitHub Developer Settings での設定
- Settings → Developer settings → OAuth Apps → New OAuth App
- Homepage URL に
http://localhost:3000、Authorization callback URL にhttp://localhost:3000/auth/github/callback - Client secrets を生成し .env に保存
6.3 /auth/github/login
// src/providers/github.ts
import { Router } from "express";
import { generateRandomState } from "../pkce.js";
export const githubRouter = Router();
const GH = {
authorization: "https://github.com/login/oauth/authorize",
token: "https://github.com/login/oauth/access_token",
userinfo: "https://api.github.com/user",
};
githubRouter.get("/login", (req, res) => {
const state = generateRandomState();
req.session.state = state;
req.session.provider = "github";
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: process.env.GITHUB_REDIRECT_URI!,
state,
scope: "read:user user:email",
allow_signup: "true",
});
res.redirect(`${GH.authorization}?${params}`);
});
6.4 /auth/github/callback
// src/providers/github.ts (続き)
githubRouter.get("/callback", async (req, res) => {
const { code, state } = req.query as Record;
if (!code || !state || state !== req.session.state) {
return res.status(400).send("invalid state");
}
const tokenRes = await fetch(GH.token, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
client_secret: process.env.GITHUB_CLIENT_SECRET!,
code,
redirect_uri: process.env.GITHUB_REDIRECT_URI!,
}),
});
const tokens = (await tokenRes.json()) as {
access_token: string;
token_type: "bearer";
scope: string;
};
// 自前で UserInfo を取りに行く
const u = await fetch(GH.userinfo, {
headers: {
authorization: `Bearer ${tokens.access_token}`,
accept: "application/vnd.github+json",
"user-agent": "oauth-handson",
},
});
const user = await u.json() as { id: number; login: string; name?: string };
req.session.user = {
sub: `github:${user.id}`,
name: user.name ?? user.login,
};
req.session.tokens = {
access_token: tokens.access_token,
expires_at: Date.now() + 1000 * 60 * 60 * 8,
};
delete req.session.state;
res.redirect("/");
});
7. X(Twitter)OAuth 2.0 と Apple Sign-In
7.1 X(旧 Twitter)OAuth 2.0 with PKCE
X は 2021 年に OAuth 2.0 + PKCE に対応しました。Confidential Client(Web サーバー)でも PKCE 必須です。
// src/providers/x.ts
import { Router } from "express";
import {
generateCodeVerifier,
generateCodeChallenge,
generateRandomState,
} from "../pkce.js";
export const xRouter = Router();
const X = {
authorization: "https://twitter.com/i/oauth2/authorize",
token: "https://api.twitter.com/2/oauth2/token",
userinfo: "https://api.twitter.com/2/users/me",
};
xRouter.get("/login", (req, res) => {
const state = generateRandomState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
req.session.state = state;
req.session.codeVerifier = codeVerifier;
const params = new URLSearchParams({
client_id: process.env.X_CLIENT_ID!,
redirect_uri: process.env.X_REDIRECT_URI!,
response_type: "code",
scope: "tweet.read users.read offline.access",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`${X.authorization}?${params}`);
});
7.2 X のトークン交換(Basic 認証ヘッダ)
xRouter.get("/callback", async (req, res) => {
const { code, state } = req.query as Record;
if (!code || state !== req.session.state) {
return res.status(400).send("invalid state");
}
const basic = Buffer.from(
`${process.env.X_CLIENT_ID}:${process.env.X_CLIENT_SECRET}`,
).toString("base64");
const r = await fetch(X.token, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${basic}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: process.env.X_REDIRECT_URI!,
code_verifier: req.session.codeVerifier!,
}),
});
const tokens = await r.json();
// 以降は同様
res.redirect("/");
});
7.3 Apple Sign-In(Sign in with Apple)
Apple は OIDC 準拠ですが、クライアントシークレットを JWT で動的生成する独特の方式です。ES256(P-256)で署名した JWT を client_secret として送ります。
// src/providers/apple-secret.ts
import { SignJWT, importPKCS8 } from "jose";
export async function buildAppleClientSecret(opts: {
teamId: string;
clientId: string; // services id
keyId: string;
privateKeyPem: string;
}) {
const key = await importPKCS8(opts.privateKeyPem, "ES256");
return new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: opts.keyId })
.setIssuer(opts.teamId)
.setIssuedAt()
.setExpirationTime("180d")
.setAudience("https://appleid.apple.com")
.setSubject(opts.clientId)
.sign(key);
}
7.4 Apple OIDC エンドポイント
// src/providers/apple-endpoints.ts
export const APPLE = {
authorization: "https://appleid.apple.com/auth/authorize",
token: "https://appleid.apple.com/auth/token",
jwks: "https://appleid.apple.com/auth/keys",
issuer: "https://appleid.apple.com",
} as const;
8. Refresh Token・Introspection・Revocation
8.1 Refresh Token とは
access_token は短命(通常 1 時間)です。失効時に毎回ユーザーに再認可させると UX が破綻するため、Authorization Server が長命の refresh_token を一緒に発行します。Refresh Token を使って access_token を再発行する仕組みです。
8.2 Refresh Token で access_token を更新
// src/refresh.ts
import { GOOGLE } from "./providers/google-endpoints.js";
export async function refreshGoogleAccessToken(refreshToken: string) {
const r = await fetch(GOOGLE.token, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
}),
});
if (!r.ok) throw new Error(`refresh failed: ${await r.text()}`);
return (await r.json()) as {
access_token: string;
expires_in: number;
scope: string;
token_type: "Bearer";
id_token?: string;
// 一部のIdPは refresh_token も rotate して再発行する
refresh_token?: string;
};
}
8.3 Token を自動更新する middleware
// src/middleware/ensure-token.ts
import type { RequestHandler } from "express";
import { refreshGoogleAccessToken } from "../refresh.js";
export const ensureFreshToken: RequestHandler = async (req, res, next) => {
const t = req.session.tokens;
if (!t) return res.status(401).send("login required");
if (Date.now() < t.expires_at - 60_000) return next();
if (!t.refresh_token) return res.status(401).send("re-login required");
try {
const fresh = await refreshGoogleAccessToken(t.refresh_token);
req.session.tokens = {
access_token: fresh.access_token,
refresh_token: fresh.refresh_token ?? t.refresh_token,
id_token: fresh.id_token ?? t.id_token,
expires_at: Date.now() + fresh.expires_in * 1000,
};
next();
} catch (e) {
res.status(401).send("token refresh failed");
}
};
8.4 Token Introspection(RFC 7662)
Introspection は、Resource Server が「もらった access_token が今も有効か」を Authorization Server に問い合わせる仕組みです。自前 IdP(Keycloak など)や Auth0 で利用できます。
// src/introspect.ts
type IntrospectResult = {
active: boolean;
scope?: string;
client_id?: string;
username?: string;
exp?: number;
iat?: number;
sub?: string;
};
export async function introspect(token: string): Promise {
const basic = Buffer.from(
`${process.env.IDP_CLIENT_ID}:${process.env.IDP_CLIENT_SECRET}`,
).toString("base64");
const r = await fetch(process.env.IDP_INTROSPECT_URL!, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${basic}`,
},
body: new URLSearchParams({ token, token_type_hint: "access_token" }),
});
return (await r.json()) as IntrospectResult;
}
8.5 Token Revocation(RFC 7009)
// src/revoke.ts
import { GOOGLE } from "./providers/google-endpoints.js";
export async function revokeGoogleToken(token: string) {
const r = await fetch(GOOGLE.revocation, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token }),
});
if (!r.ok) throw new Error(`revoke failed: ${await r.text()}`);
}
9. Bearer Token を API サーバーで検証する(Resource Server 側)
9.1 Authorization ヘッダの形式
GET /api/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/json
9.2 Bearer 検証 middleware(自前 IdP / Cognito / Auth0 共通)
// src/middleware/bearer.ts
import type { RequestHandler } from "express";
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URI!));
declare module "express-serve-static-core" {
interface Request {
user?: { sub: string; scope: string[] };
}
}
export const requireBearer: RequestHandler = async (req, res, next) => {
const h = req.headers.authorization;
if (!h?.startsWith("Bearer ")) {
return res
.status(401)
.set("www-authenticate", `Bearer realm="api"`)
.send("missing bearer");
}
const token = h.slice("Bearer ".length).trim();
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: process.env.ISSUER!,
audience: process.env.AUDIENCE!,
algorithms: ["RS256", "ES256"],
});
req.user = {
sub: payload.sub as string,
scope: (payload.scope as string)?.split(" ") ?? [],
};
next();
} catch (e) {
res
.status(401)
.set("www-authenticate", `Bearer error="invalid_token"`)
.send("invalid token");
}
};
9.3 scope ベースの認可
// src/middleware/scope.ts
import type { RequestHandler } from "express";
export function requireScope(needed: string): RequestHandler {
return (req, res, next) => {
if (!req.user) return res.status(401).send("unauthorized");
if (!req.user.scope.includes(needed)) {
return res
.status(403)
.set(
"www-authenticate",
`Bearer error="insufficient_scope", scope="${needed}"`,
)
.send("forbidden");
}
next();
};
}
9.4 ルートで使う
// src/api.ts
import { Router } from "express";
import { requireBearer } from "./middleware/bearer.js";
import { requireScope } from "./middleware/scope.js";
export const api = Router();
api.use(requireBearer);
api.get("/me", (req, res) => res.json({ sub: req.user!.sub }));
api.post("/admin", requireScope("admin"), (req, res) => res.send("ok"));
10. Client Credentials(サーバー間 OAuth)
10.1 ユースケース
バッチ処理・cron・マイクロサービス間通信など、ユーザーが介在しないサーバー間 API 呼び出しで使います。ユーザーの代理ではなく、クライアント(アプリ)自身が主体です。
10.2 トークン取得
// src/m2m.ts
type M2MToken = { access_token: string; expires_in: number };
let cache: { token: string; expAt: number } | null = null;
export async function getM2MToken(): Promise {
if (cache && Date.now() < cache.expAt - 60_000) return cache.token;
const basic = Buffer.from(
`${process.env.M2M_CLIENT_ID}:${process.env.M2M_CLIENT_SECRET}`,
).toString("base64");
const r = await fetch(process.env.M2M_TOKEN_URL!, {
method: "POST",
headers: {
authorization: `Basic ${basic}`,
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "service:read service:write",
audience: process.env.M2M_AUDIENCE!, // Auth0/Cognito等で必要
}),
});
if (!r.ok) throw new Error(`m2m failed: ${await r.text()}`);
const j = (await r.json()) as M2MToken;
cache = { token: j.access_token, expAt: Date.now() + j.expires_in * 1000 };
return j.access_token;
}
10.3 M2M トークンで API を呼ぶ
// src/m2m-call.ts
import { getM2MToken } from "./m2m.js";
export async function callInternalApi(path: string) {
const token = await getM2MToken();
const r = await fetch(`https://internal.example.com${path}`, {
headers: { authorization: `Bearer ${token}` },
});
return r.json();
}
11. openid-client で書き直す(現場の定番ライブラリ)
11.1 なぜ openid-client か
ここまでは学習目的でフルスクラッチで書きましたが、本番では Filip Skokan 氏の openid-client を使うのが定番です。OIDC Discovery、JWKs キャッシュ、PKCE、nonce 検証、JAR / JARM までカバーし、認定実装(Certified)でもあります。
11.2 OIDC Discovery で IdP 設定を自動取得
// src/oidc.ts
import { Issuer, generators, Client } from "openid-client";
export async function buildGoogleClient(): Promise {
const google = await Issuer.discover("https://accounts.google.com");
return new google.Client({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uris: [process.env.GOOGLE_REDIRECT_URI!],
response_types: ["code"],
});
}
export { generators };
11.3 openid-client での login / callback
// src/providers/google-oidcclient.ts
import { Router } from "express";
import { buildGoogleClient, generators } from "../oidc.js";
export const r = Router();
const clientPromise = buildGoogleClient();
r.get("/login", async (req, res) => {
const client = await clientPromise;
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
const nonce = generators.nonce();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
req.session.nonce = nonce;
res.redirect(
client.authorizationUrl({
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
access_type: "offline",
prompt: "consent",
}),
);
});
r.get("/callback", async (req, res) => {
const client = await clientPromise;
const params = client.callbackParams(req);
const tokenSet = await client.callback(
process.env.GOOGLE_REDIRECT_URI!,
params,
{
code_verifier: req.session.codeVerifier,
state: req.session.state,
nonce: req.session.nonce,
},
);
const claims = tokenSet.claims();
req.session.user = {
sub: claims.sub,
email: claims.email,
name: claims.name,
};
req.session.tokens = {
access_token: tokenSet.access_token!,
refresh_token: tokenSet.refresh_token,
id_token: tokenSet.id_token,
expires_at: tokenSet.expires_at! * 1000,
};
res.redirect("/");
});
12. Auth.js(NextAuth v5)で OAuth を Next.js に乗せる
12.1 インストール
npm i next-auth@beta
12.2 auth.ts(App Router 用)
// auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: { params: { scope: "openid email profile" } },
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
return token;
},
async session({ session, token }) {
(session as any).accessToken = token.accessToken;
return session;
},
},
});
12.3 app/api/auth/[…nextauth]/route.ts
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";
12.4 サーバーコンポーネントでセッション取得
// app/page.tsx
import { auth, signIn, signOut } from "@/auth";
export default async function Home() {
const session = await auth();
if (!session) {
return (
{ "use server"; await signIn("google"); }}>
);
}
return (
Hello, {session.user?.name}
{ "use server"; await signOut(); }}>
>
);
}
</code>
13. iron-session・Lucia・マネージド IdP の使い分け
13.1 iron-session(ステートレス暗号化セッション)
// src/iron.ts
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";
type AppSession = { user?: { sub: string; name?: string } };
const opts: SessionOptions = {
password: process.env.IRON_PASSWORD!, // 32文字以上
cookieName: "app_session",
cookieOptions: { secure: true, httpOnly: true, sameSite: "lax" },
};
export async function getSession() {
return getIronSession(await cookies(), opts);
}
13.2 Lucia Auth(セッション中心の軽量ライブラリ)
// src/lucia.ts (Lucia v3)
import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import Database from "better-sqlite3";
const db = new Database("auth.db");
const adapter = new BetterSqlite3Adapter(db, {
user: "user",
session: "session",
});
export const lucia = new Lucia(adapter, {
sessionCookie: { attributes: { secure: true } },
getUserAttributes: (data) => ({
googleId: data.google_id,
name: data.name,
}),
});
13.3 Clerk / Auth0 / WorkOS 比較
| サービス | 強み | 向き |
|---|---|---|
| Clerk | Next.js統合・組織機能・UIコンポ豊富 | B2C SaaS の高速立ち上げ |
| Auth0 | 老舗・Rules/Actions柔軟・SAML対応 | エンタープライズ全般 |
| WorkOS | B2B SSO/SCIM特化・SAML楽 | 大企業向け SaaS |
| Supabase Auth | DBと一体・Row Level Security連携 | Postgres中心アプリ |
13.4 Clerk の最小コード
// app/layout.tsx (Next.js)
import { ClerkProvider, SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
14. セキュリティ・OWASP・ベストプラクティス
14.1 リダイレクト URI ベストプラクティス
- 完全一致のみ(ワイルドカード・パスプレフィックス禁止)
- 本番は https のみ、開発は http://localhost のみ許容
- 動的 redirect は
stateパラメータの中に入れて検証する(open redirect 対策) - OAuth 2.1 では完全一致が必須化された
14.2 PKCE はなぜ Confidential Client にも必須なのか
従来 PKCE は SPA・モバイルアプリ(Public Client)向けの仕様でしたが、Confidential Client でも 認可コード横取り攻撃を防ぐ価値があるため、OAuth 2.1 ドラフトと FAPI ですべてのクライアントに必須化されました。「サーバーが持ってる秘密鍵があるから PKCE 不要」という時代は終わっています。
14.3 トークンの保存場所
| 保存先 | XSS | CSRF | 推奨 |
|---|---|---|---|
| localStorage | 脆弱 | 影響なし | × |
| JS可読 cookie | 脆弱 | 対策必要 | × |
| HttpOnly+Secure cookie | 安全 | SameSite=Lax/Strict で対策 | ○ |
| サーバー側セッション | 安全 | 対策容易 | ◎ |
14.4 Cookie 設定の決定版
// src/cookie.ts
export const SECURE_COOKIE = {
httpOnly: true,
secure: true, // 本番は必ず true
sameSite: "lax" as const, // OAuth 戻りのため strict は避ける
path: "/",
maxAge: 1000 * 60 * 60 * 8,
} as const;
14.5 OWASP OAuth ベストプラクティス(要点)
- state パラメータを必ず検証する(CSRF / mix-up 対策)
- nonce を ID Token と照合する(リプレイ対策)
- PKCE S256(plain は使わない)
- scope は最小権限で要求する
- refresh_token は rotate(IdP 側設定)で再利用検知
- id_token は localStorage に置かない
- access_token を URL クエリで送らない(ログ漏れ防止)
14.6 mix-up 攻撃の対策
複数 IdP に同時にログインボタンを置く場合、攻撃者が「Google のつもりで認可コードを GitHub へ渡す」ような mix-up 攻撃があり得ます。state にプロバイダ識別子を入れて検証してください。
// src/state.ts
import { createHmac } from "node:crypto";
export function signedState(payload: { provider: string; r: string }) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = createHmac("sha256", process.env.STATE_SECRET!)
.update(body)
.digest("base64url");
return `${body}.${sig}`;
}
export function verifySignedState(token: string) {
const [body, sig] = token.split(".");
const expected = createHmac("sha256", process.env.STATE_SECRET!)
.update(body)
.digest("base64url");
if (expected !== sig) throw new Error("invalid state signature");
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
}
15. SAML との比較・関連仕様
15.1 SAML vs OIDC
| 観点 | SAML 2.0 | OIDC |
|---|---|---|
| フォーマット | XML | JSON / JWT |
| 主な用途 | エンタープライズ SSO | Web/モバイル/API |
| 主な経路 | HTTP POST Binding | HTTP Redirect + POST |
| モバイル対応 | 弱い | 得意 |
| 採用 | Okta/AD FS/大手SaaS | モダン全般 |
15.2 関連 RFC・仕様
- RFC 6749 - OAuth 2.0 Authorization Framework
- RFC 6750 - Bearer Token Usage
- RFC 7009 - Token Revocation
- RFC 7519 - JSON Web Token (JWT)
- RFC 7636 - PKCE
- RFC 7662 - Token Introspection
- RFC 8252 - OAuth 2.0 for Native Apps
- RFC 8414 - OAuth 2.0 Authorization Server Metadata
- RFC 8628 - Device Authorization Grant
- RFC 8693 - Token Exchange
- OpenID Connect Core 1.0 / Discovery 1.0
15.3 認可コードを使い回せない理由(one-time use)
認可コードは 一度だけ使えます。二度目の /token 交換は Authorization Server がエラーを返し、さらに最初に発行したアクセストークンも 失効させるのが推奨実装です(RFC 6749 4.1.2)。コードが漏れたかもしれない場合は強制ログアウトに繋げます。
16. テスト・運用・ハマりどころ
16.1 ローカルで HTTPS が必要な場合(mkcert)
brew install mkcert # macOS
mkcert -install
mkcert localhost
# localhost.pem / localhost-key.pem が生成される
16.2 Express で HTTPS 起動
// src/https.ts
import https from "node:https";
import fs from "node:fs";
import app from "./server.js";
https
.createServer(
{
key: fs.readFileSync("localhost-key.pem"),
cert: fs.readFileSync("localhost.pem"),
},
app,
)
.listen(3443, () => console.log("https://localhost:3443"));
16.3 Vitest で callback をテスト(モック JWKs)
// tests/jwt-verify.test.ts
import { describe, it, expect } from "vitest";
import { SignJWT, generateKeyPair, exportJWK } from "jose";
describe("verifyIdToken", () => {
it("rejects mismatched audience", async () => {
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwt = await new SignJWT({ nonce: "n" })
.setProtectedHeader({ alg: "RS256", kid: "test" })
.setIssuer("https://test")
.setAudience("wrong-aud")
.setSubject("u1")
.setExpirationTime("5m")
.sign(privateKey);
// 検証 (audience: 'right-aud' を期待) は失敗するはず
expect(jwt).toBeTruthy();
});
});
16.4 よくあるエラーと原因
| 症状 | 原因 |
|---|---|
| redirect_uri_mismatch | Console 登録 URI と完全一致していない(末尾スラッシュ/ポート違い) |
| invalid_grant | code 期限切れ・二回交換・code_verifier 不一致 |
| refresh_token が来ない | access_type=offline と prompt=consent が無い(Google) |
| nonce mismatch | セッション側 nonce が消えている(セッション cookie が cross-site で落ちている) |
| audience invalid | jwtVerify の audience に client_id を渡し忘れ |
16.5 OIDC Discovery を curl で確かめる
curl -s https://accounts.google.com/.well-known/openid-configuration | jq
# 重要キー
# - authorization_endpoint
# - token_endpoint
# - jwks_uri
# - issuer
# - userinfo_endpoint
# - revocation_endpoint
# - code_challenge_methods_supported (S256 必須)
16.6 ID Token を手元で覗く(本番では検証必須)
# jwt CLI(npm i -g jwt-cli)で payload を眺める
echo "$ID_TOKEN" | jwt decode -
# あるいは Node ワンライナー
node -e 'const t=process.argv[1].split(".")[1];
console.log(JSON.parse(Buffer.from(t,"base64url").toString()))' $ID_TOKEN
17. プライバシー・スコープ設計・運用観点
17.1 最小権限の原則(scope)
「とりあえず全 scope を要求」する設計は、ユーザーの同意率を下げ、漏洩時のリスクを増やします。機能が必要になった時点で incremental authorization で追加要求するのが Google が推奨する設計です。
17.2 Google の incremental authorization
// 後から追加スコープを要求(include_granted_scopes)
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
response_type: "code",
scope: "openid email profile https://www.googleapis.com/auth/drive.readonly",
state, nonce,
code_challenge: cc, code_challenge_method: "S256",
include_granted_scopes: "true",
});
17.3 同意取り消しを受け止める(Google アカウント連携解除)
ユーザーが Google アカウント設定からアプリ連携を解除すると refresh_token が無効化されます。定期実行ジョブで refresh エラーを検知し、ユーザーに再ログインを促す UX を組み込みましょう。
17.4 GDPR / 個人情報保護法の観点
- id_token に含まれる email / name はそれ自体が個人情報
- ログに ID Token を出さない(
redact必須) - サブジェクト識別子は
subのみで保存し、表示は最小化 - アカウント削除時はトークン Revocation と DB 削除を両方
17.5 ログから機微情報を伏せる(pino)
// src/logger.ts
import pino from "pino";
export const logger = pino({
redact: {
paths: [
"*.access_token",
"*.id_token",
"*.refresh_token",
"*.code",
"*.code_verifier",
"headers.authorization",
"headers.cookie",
],
censor: "[REDACTED]",
},
});
18. まとめと次に学ぶこと
18.1 この記事で押さえたポイント
- OAuth 2.0 は 認可、OIDC は 認証(ID Token を返す)
- 2026 年現在の標準は Authorization Code Flow + PKCE 一択
- state / nonce / code_verifier をセッションに保管し、callback で必ず検証
- ID Token は jose で JWKs + algorithms ホワイトリスト + iss/aud/nonce を検証
- 本番は openid-client / Auth.js / Clerk / Auth0 などの実装に乗る
- トークンはサーバー側セッションか HttpOnly+Secure cookie に保管
18.2 次に読むべき関連記事(同サイト内)
- Express 5 完全実践ガイド — middleware・認証・REST 設計の土台
- REST API 設計完全ガイド — Bearer 認証・OWASP API Top 10
- NestJS 完全実践ガイド — Guard で OAuth Bearer を実装する
- Hono 完全実践ガイド — Cloudflare Workers 上の OIDC 実装
18.3 学習を最短化したい場合(スクール紹介)
OAuth / OIDC は実装を間違えると即セキュリティ事故になるため、独学だけでなく 現役エンジニアのレビューを受けながら手を動かすのが安全です。以下のスクールは認証認可や Web セキュリティを含む実践課題を提供しています。
- テックアカデミー:オンライン完結・週 2 回のメンタリングで OAuth 実装課題までフォロー
- 侍エンジニア:オーダーメイドカリキュラムで認証認可・API 設計を集中的に学べる
- DMM WEBCAMP:現役エンジニアによる質問対応無制限、実務想定の課題が豊富
- レバテックカレッジ:大学生向け短期集中、Web セキュリティ基礎まで学習可能
「ハンズオンで OAuth/OIDC を扱う認証認可レイヤーをポートフォリオに載せる」だけで、Web エンジニア採用面接の通過率は明確に上がります。本記事のコードをベースに、Google + GitHub の 2 系統ログインを実装して GitHub に push しておくのを強くおすすめします。
- [ ] Authorization Code Flow + PKCE を実装した
- [ ] state / nonce / code_verifier をセッションに保管・検証している
- [ ] ID Token を jose で JWKs 検証・algorithms ホワイトリスト指定
- [ ] redirect_uri は完全一致(本番 https のみ)
- [ ] Cookie は HttpOnly + Secure + SameSite=Lax
- [ ] refresh_token を rotate 設定で運用
- [ ] Token Revocation を logout 時に呼んでいる
- [ ] ログから access_token / id_token / refresh_token を redact
- [ ] 最小権限の scope のみ要求(incremental authorization 活用)
- [ ] Vitest で nonce/audience/expiration の境界ケースを回している

コメント