- 概要 — SSOとハイブリッド認証アーキテクチャ
- OIDC/OAuth2プロトコル詳解
- ハイブリッドアーキテクチャ設計
- SSO実装実践
- トークンローテーション戦略
- Cookie/JWTハイブリッドパターン詳細
- ブラウザストレージ別アクセス可能性表
- CORS + マルチドメインSSO
- ログアウト戦略
- トークン無効化とブラックリスト
- マルチサービス認証(マイクロサービス)
- セキュリティトレードオフ総合
- フレームワーク別要約比較表
- 統合チェックリスト
- よくあるバグと誤解
- 参考資料
📚 SSO Cookie/JWT 認証シリーズ > Next.js 編 ← 現在: 統合実践編 | シリーズインデックス
概要 — SSOとハイブリッド認証アーキテクチャ
SSOとは何か
Single Sign-On(SSO) は、ユーザーが 1 つの資格情報で複数のアプリケーションやサービスにアクセスできる認証の仕組みです。一度ログインすれば、同じ組織内のメール、Wiki、CI/CD、社内管理ツールなどへ別途のログインなしでアクセスできます。
[ユーザー] ──ログイン──▶ [IdP (Identity Provider)]
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[アプリ A] [アプリ B] [アプリ C]
(メール) (Wiki) (CI/CD)
再ログイン不要 再ログイン不要 再ログイン不要
OIDC vs OAuth2の違い
多くの開発者が混同する部分です。はっきり区別しておきます。
- OAuth 2.0: 認可 (Authorization) のフレームワーク。「このアプリが自分のリソースにアクセスしてよいか」を決めます。Access Token を発行しますが、ユーザーが誰であるかは標準としては定義しません。
- OpenID Connect (OIDC): OAuth 2.0 の上に 構築された 認証 (Authentication) レイヤー。ID Token (JWT) を発行して「このユーザーが誰なのか」を証明します。
OAuth 2.0 = 「このアプリが君の Google Drive を読んでもいい?」 (認可)
OIDC = 「ログインした人は youngjukim@example.com だよ」 (認証) + OAuth 2.0
なぜハイブリッド(Cookie+JWT)アーキテクチャが必要か
実際のプロダクション環境では、クッキーだけを使う方式も JWT だけを使う方式も、それぞれ限界があります。
- クッキーのみ: ブラウザとサーバーの間では自然ですが、マイクロサービス間の伝播には向かず、モバイルアプリでは使いにくくなります。
- JWT のみ: サービス間の伝播には有利ですが、ブラウザで安全に保存するのが難しく (XSS に脆弱)、即時の無効化ができません。
ハイブリッドアーキテクチャは、ブラウザ ↔ BFF の区間では HttpOnly のセッションクッキーを、BFF ↔ バックエンドサービス の区間では JWT を使い、双方の長所を取ります。
マイクロサービス環境での認証の課題
- 数十のサービスがそれぞれ認証を実装すると、保守コストが指数関数的に増えます。
- サービス間の呼び出しでは、トークンの伝播、audience の検証、トークン期限切れのハンドリングを統一する必要があります。
- セッションの一貫性: あるサービスでログアウトしたら、他のすべてのサービスでも直ちにログアウトされる必要があります。
OIDC/OAuth2プロトコル詳解
Authorization Code Flow + PKCE
最も安全な認証フローであり、すべてのクライアント種別 (Web、モバイル、SPA) に推奨されます。
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ BFF │ │ IdP │
│ (User) │ │ (Server) │ │(Keycloak)│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 1. /login をクリック │ │
│─────────────────────────────────────▶│ │
│ │ 2. code_verifier を生成 │
│ │ code_challenge = │
│ │ SHA256(code_verifier) │
│ 3. 302 Redirect to IdP │ │
│◀─────────────────────────────────────│ │
│ (/authorize?response_type=code │ │
│ &client_id=... │ │
│ &code_challenge=... │ │
│ &code_challenge_method=S256) │ │
│──────────────────────────────────────────────────────────────────────▶│
│ │ 4. ユーザーのログイン + 同意 │
│◀──────────────────────────────────────────────────────────────────────│
│ 5. redirect_uri?code=AUTH_CODE │ │
│─────────────────────────────────────▶│ │
│ │ 6. POST /token │
│ │ code + code_verifier │
│ │─────────────────────────────▶│
│ │ 7. AT + RT + ID Token │
│ │◀─────────────────────────────│
│ 8. Set-Cookie: session_id │ │
│◀─────────────────────────────────────│ │
PKCE (Proof Key for Code Exchange, RFC 7636) は、Authorization Code を横取りする攻撃を防ぎます。
# Python: PKCE の code_verifier / code_challenge の生成
import secrets
import hashlib
import base64
# 1. code_verifier: 43~128 文字のランダム文字列
code_verifier = secrets.token_urlsafe(64)[:128]
# 2. code_challenge: SHA256 ハッシュ後に Base64url エンコード
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
print(f"code_verifier: {code_verifier}")
print(f"code_challenge: {code_challenge}")
ID Token vs Access Token vs Refresh Token
| トークン | 目的 | 寿命 | 受信者 | 形式 |
|---|---|---|---|---|
| ID Token | ユーザー認証情報の伝達 | 5~60 分 | クライアントアプリ | JWT (必須) |
| Access Token | APIアクセス権限の付与 | 5~60 分 | リソースサーバー | JWT または Opaque |
| Refresh Token | 新AT/RTの発行 | 数日~数ヶ月 | Authorization Serverのみ | Opaque 推奨 |
ID Token の主要なクレーム:
{
"iss": "https://idp.example.com",
"sub": "user-uuid-1234",
"aud": "my-client-id",
"exp": 1709913600,
"iat": 1709910000,
"nonce": "abc123xyz",
"email": "youngjukim@example.com",
"name": "キム・ヨンジュ",
"email_verified": true
}
sub: ユーザーの一意な識別子 (決して変更されない)aud: このトークンを発行されたクライアント ID (必ず検証すること)nonce: リプレイ攻撃の防止用 (認証リクエスト時に渡した値との一致を確認する)
Access Token の scope の例:
scope: "openid profile email read:calendar write:calendar"
ハイブリッドアーキテクチャ設計
BFF(Backend For Frontend)パターン
BFF パターンはハイブリッド認証の中核です。トークンは BFF サーバーにのみ存在し、ブラウザにはセッションクッキーだけを渡します。
┌─────────────────────────────────────────────────────┐
│ Browser │
│ セッションクッキーのみ保持 (HttpOnly, Secure, SameSite=Lax) │
│ JavaScript からトークンにアクセス不可 → XSS から安全 │
└──────────────────────┬──────────────────────────────┘
│ Cookie: session_id=xxx
▼
┌──────────────────────────────────────────────────────┐
│ BFF (Backend For Frontend) │
│ ┌──────────────────────────────────────────────┐ │
│ │ セッションストア (Redis) │ │
│ │ session_id → { access_token, refresh_token, │ │
│ │ id_token, user_info } │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│ Authorization: Bearer <JWT>
▼
┌──────────────────────────────────────────────────────┐
│ Backend Microservices │
│ [User API] [Order API] [Payment API] [...] │
└──────────────────────────────────────────────────────┘
セキュリティ上の利点:
- Access Token と Refresh Token がブラウザに露出しないため、XSS 攻撃によるトークン窃取が不可能
- CSRF への防御は SameSite クッキー + CSRF トークンで解決
- トークン更新のロジックがサーバーに集約され、クライアント側の複雑さが減る
Token Relayパターン
API Gateway でセッションクッキーを JWT に変換してバックエンドへ渡します。
# Spring Cloud Gateway - Token Relay の設定
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- TokenRelay= # Access Token を自動で Authorization ヘッダーに追加
- RemoveRequestHeader=Cookie # クッキーはバックエンドへ渡さない
SSO実装実践
Keycloak連携例
Spring Boot + Spring Security OAuth2 Client:
// application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-app
client-secret: ${KEYCLOAK_SECRET}
scope: openid, profile, email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/my-realm
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/dashboard")
)
.oauth2Client(Customizer.withDefaults());
return http.build();
}
}
Django + mozilla-django-oidc:
# settings.py
INSTALLED_APPS += ['mozilla_django_oidc']
AUTHENTICATION_BACKENDS = [
'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
]
OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID']
OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET']
OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth'
OIDC_OP_TOKEN_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token'
OIDC_OP_USER_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/userinfo'
OIDC_OP_JWKS_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs'
OIDC_RP_SIGN_ALGO = 'RS256'
# urls.py
urlpatterns += [
path('oidc/', include('mozilla_django_oidc.urls')),
]
Next.js + NextAuth.js OIDC Provider:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'
const handler = NextAuth({
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER, // https://keycloak.example.com/realms/my-realm
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at
}
// 期限切れ前に更新
if (Date.now() < (token.expiresAt as number) * 1000) {
return token
}
return refreshAccessToken(token)
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
return session
},
},
})
export { handler as GET, handler as POST }
Google/Azure AD/Okta連携
OIDC の Discovery endpoint (.well-known/openid-configuration) を活用すれば、どの IdP でも同じパターンで連携できます。
Google: https://accounts.google.com/.well-known/openid-configuration
Azure AD: https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Okta: https://{domain}.okta.com/.well-known/openid-configuration
Keycloak: https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration
// 汎用的な OIDC Discovery の活用 (Node.js)
import { Issuer } from 'openid-client'
async function setupOIDC(issuerUrl: string, clientId: string, clientSecret: string) {
const issuer = await Issuer.discover(issuerUrl)
console.log('Authorization Endpoint:', issuer.metadata.authorization_endpoint)
console.log('Token Endpoint:', issuer.metadata.token_endpoint)
console.log('UserInfo Endpoint:', issuer.metadata.userinfo_endpoint)
console.log('JWKS URI:', issuer.metadata.jwks_uri)
const client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: ['https://myapp.example.com/callback'],
response_types: ['code'],
})
return client
}
トークンローテーション戦略
Refresh Token Rotation
Refresh Token Rotation は、窃取されたトークンが再利用されるのを検知するための中核となるセキュリティ機構です。
[正常な流れ]
Client → POST /token (grant_type=refresh_token, refresh_token=RT_1)
Server → 新しい AT_2 + 新しい RT_2 を発行、RT_1 を無効化
[窃取シナリオ — Automatic Reuse Detection]
攻撃者が RT_1 を窃取して使用:
攻撃者 → POST /token (refresh_token=RT_1) ← すでに使用済みの RT!
Server → RT_1 がすでに使用済みであることを検知
→ RT_1 のトークンファミリー全体 (RT_2, RT_3 ...) をすべて無効化
→ ユーザーに強制的な再ログインを要求
// Node.js / Express: Refresh Token Rotation の実装
import { randomUUID } from 'crypto'
import Redis from 'ioredis'
const redis = new Redis()
interface TokenFamily {
userId: string
familyId: string
usedTokens: Set<string>
}
async function rotateRefreshToken(currentRefreshToken: string) {
const tokenData = await redis.get(`rt:${currentRefreshToken}`)
if (!tokenData) {
throw new Error('INVALID_REFRESH_TOKEN')
}
const parsed = JSON.parse(tokenData)
const familyKey = `family:${parsed.familyId}`
// Reuse Detection: すでに使用済みのトークンならファミリー全体を無効化
const isUsed = await redis.sismember(`${familyKey}:used`, currentRefreshToken)
if (isUsed) {
console.warn(`[SECURITY] Token reuse detected! Family: ${parsed.familyId}`)
await revokeTokenFamily(parsed.familyId)
throw new Error('TOKEN_REUSE_DETECTED')
}
// 現在の RT を「使用済み」として記録
await redis.sadd(`${familyKey}:used`, currentRefreshToken)
// 新しいトークンを発行
const newRefreshToken = randomUUID()
const newAccessToken = generateJWT(parsed.userId)
await redis.setex(
`rt:${newRefreshToken}`,
7 * 24 * 3600,
JSON.stringify({
userId: parsed.userId,
familyId: parsed.familyId,
createdAt: Date.now(),
})
)
// 既存の RT を削除
await redis.del(`rt:${currentRefreshToken}`)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
async function revokeTokenFamily(familyId: string) {
const members = await redis.smembers(`family:${familyId}:used`)
const pipeline = redis.pipeline()
for (const token of members) {
pipeline.del(`rt:${token}`)
}
pipeline.del(`family:${familyId}:used`)
await pipeline.exec()
}
Access Token更新タイミング
| 戦略 | 方式 | 長所 | 短所 |
|---|---|---|---|
| Proactive (事前更新) | 期限の 30~60 秒前に先回りして更新 | ユーザー体験が途切れない | 不要な更新が起こりうる |
| Reactive (反応更新) | 401 応答を受けてから更新し再試行 | 実装が単純 | 初回リクエスト失敗 + 遅延 |
| Hybrid | タイマーによる事前更新 + 401 フォールバック | 両方の長所を結合 | 実装の複雑さが増す |
// Axios インターセプタ: Hybrid な更新戦略
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
const api = axios.create({ baseURL: '/api', withCredentials: true })
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// すでに更新中ならキューに追加する
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(() => api(originalRequest))
}
originalRequest._retry = true
isRefreshing = true
try {
await axios.post('/api/auth/refresh', {}, { withCredentials: true })
failedQueue.forEach(({ resolve }) => resolve())
failedQueue = []
return api(originalRequest)
} catch (refreshError) {
failedQueue.forEach(({ reject }) => reject(refreshError))
failedQueue = []
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
Cookie/JWTハイブリッドパターン詳細
実務で推奨される保存戦略は次のとおりです。
| データ | 保存場所 | 理由 |
|---|---|---|
| Session ID | HttpOnly Secure クッキー | JSアクセス不可、自動送信 |
| Access Token | BFF サーバーのメモリ/Redis | ブラウザへの露出を防ぐ |
| Refresh Token | BFF サーバーの Redis (暗号化) | 長期トークンは必ずサーバー保管 |
| CSRF Token | non-HttpOnly クッキーまたはヘッダー | JS で読んでヘッダーに含める |
実務での Set-Cookie 設定:
// Express.js: セッションクッキーの設定 (BFF)
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()
app.use(
session({
store: new RedisStore({ client: redisClient }),
name: '__Host-session', // __Host- 接頭辞: Secure + 特定ドメインに固定
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript からアクセス不可
secure: true, // HTTPS でのみ送信
sameSite: 'lax', // CSRF への防御: 外部サイトからの送信を遮断
maxAge: 24 * 60 * 60 * 1000, // 24 時間
path: '/',
// domain を省略 → 現在のホストにのみ適用 (__Host- 接頭辞と併用)
},
})
)
// Set-Cookie ヘッダーの結果例:
// Set-Cookie: __Host-session=s%3Aabc123...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
ブラウザストレージ別アクセス可能性表
| ストレージ | JSアクセス | サーバー自動送信 | XSS脆弱性 | CSRF脆弱性 | 推奨用途 |
|---|---|---|---|---|---|
| HttpOnly クッキー | 不可 | 自動(same-origin) | 安全 | 脆弱 (SameSiteで緩和) | Session ID, Refresh Token |
| 通常のクッキー | 可能 | 自動 | 脆弱 | 脆弱 | CSRF Token (読み取り用) |
| localStorage | 可能 | 手動 | 脆弱 | 安全 | 非機密な設定値 |
| sessionStorage | 可能 | 手動 | 脆弱 | 安全 | タブ別一時データ |
| メモリ (変数) | 可能 | 手動 | 比較的安全 | 安全 | SPA の Access Token |
中心となる原則: 機微なトークンは HttpOnly クッキーまたはサーバーセッションに保存し、JavaScript から直接トークンを扱わないでください。
CORS + マルチドメインSSO
サブドメイン間のCookie共有
# Domain 属性を利用したサブドメイン間のクッキー共有
Set-Cookie: session=abc; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=Lax
→ app1.example.com, app2.example.com, admin.example.com すべてでクッキーが送信される
Cross-originクレデンシャル送信
// フロントエンド: credentials: 'include' の設定が必須
fetch('https://api.example.com/data', {
method: 'GET',
credentials: 'include', // クッキーを cross-origin で送信する
})
// バックエンド: CORS の設定 (Express)
app.use(
cors({
origin: 'https://app.example.com', // '*' は使用不可 (credentials 使用時)
credentials: true, // Access-Control-Allow-Credentials: true
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
})
)
Third-party Cookie制限と対応
主要なブラウザが third-party cookie を遮断または制限しています。SameSite=None; Secure を設定すれば cross-site 送信は可能ですが、それすら次第に制限されつつあります。
対応戦略:
- すべてのサービスを同じドメイン (サブドメイン) に統合する
- Token-based SSO へ移行する (クッキーの代わりに URL パラメータや postMessage を活用)
- BFF を通じたプロキシパターンを使う
ログアウト戦略
ローカルログアウト
// Express: ローカルログアウト (セッション + クッキーの削除)
app.post('/logout', async (req, res) => {
const sessionData = req.session
// 1. IdP のトークンを revoke する (任意だが推奨)
if (sessionData?.refreshToken) {
await fetch(`${ISSUER}/protocol/openid-connect/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: sessionData.refreshToken,
token_type_hint: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
}
// 2. セッションの削除
req.session.destroy((err) => {
if (err) console.error('Session destroy error:', err)
// 3. クッキーの削除
res.clearCookie('__Host-session', { path: '/', httpOnly: true, secure: true })
res.json({ success: true })
})
})
SSOグローバルログアウト
OIDC end_session_endpoint:
// ユーザーを IdP のログアウトページへリダイレクトする
const logoutUrl = new URL(`${ISSUER}/protocol/openid-connect/logout`)
logoutUrl.searchParams.set('id_token_hint', idToken)
logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com')
res.redirect(logoutUrl.toString())
Back-Channel Logout (IdP → 各サービスを直接呼び出す):
// POST /backchannel-logout エンドポイント (各サービスに実装する)
app.post('/backchannel-logout', async (req, res) => {
const { logout_token } = req.body
// logout_token (JWT) の検証
const decoded = await verifyLogoutToken(logout_token)
const userId = decoded.sub
const sessionId = decoded.sid
// 該当ユーザーのすべてのセッションを無効化
await redis.del(`user_sessions:${userId}`)
console.log(`[Back-Channel Logout] User ${userId} session ${sessionId} invalidated`)
res.sendStatus(200)
})
Front-Channel Logout (IdP → iframe で各サービスのログアウト URL を呼ぶ):
<!-- IdP がレンダリングするログアウトページに各 RP のログアウト iframe を挿入 -->
<iframe src="https://app1.example.com/logout?sid=xxx" width="0" height="0"></iframe>
<iframe src="https://app2.example.com/logout?sid=xxx" width="0" height="0"></iframe>
参考: Back-Channel Logout のほうが Front-Channel より信頼性が高いです。Front-Channel はブラウザ終了時には動作せず、third-party cookie の制限の影響も受けます。
トークン無効化とブラックリスト
Stateless JWTの無効化の限界
JWT は署名だけで検証できる self-contained なトークンなので、発行後から期限切れまでサーバー側で無効化できません。これが JWT の本質的な限界です。
Redisベースのブラックリスト
// JWT ブラックリストのミドルウェア
async function jwtBlacklistMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.sendStatus(401)
// ブラックリストの確認 (jti またはトークンのハッシュ)
const decoded = jwt.decode(token) as { jti: string; exp: number }
const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`)
if (isBlacklisted) {
return res.status(401).json({ error: 'Token has been revoked' })
}
// 通常の JWT 検証
try {
req.user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] })
next()
} catch {
return res.sendStatus(401)
}
}
// ブラックリストへの登録 (ログアウトまたは管理者による強制無効化)
async function revokeToken(token: string) {
const decoded = jwt.decode(token) as { jti: string; exp: number }
const ttl = decoded.exp - Math.floor(Date.now() / 1000)
if (ttl > 0) {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1')
}
}
Token Introspection(RFC 7662)
Opaque トークンを使うとき、リソースサーバーが Authorization Server にトークンの有効性を直接問い合わせる方式です。
POST /token/introspect HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=abc123opaque_token
→ レスポンス:
{
"active": true,
"sub": "user-1234",
"scope": "openid profile",
"client_id": "my-app",
"exp": 1709913600
}
実務で推奨される組み合わせ: 短命の Access Token (5~15 分) + Refresh Token Rotation により、ブラックリストなしでも実質的な無効化の効果が得られます。AT が短ければ窃取されても早く期限切れになり、RT はローテーションによって再利用が検知されます。
マルチサービス認証(マイクロサービス)
API GatewayでのJWT検証
# Kong Gateway: JWT 検証プラグインの設定
plugins:
- name: jwt
config:
uri_param_names: []
header_names: ['Authorization']
claims_to_verify:
- exp
key_claim_name: iss
secret_is_base64: false
// Go: API Gateway の JWT 検証ミドルウェア
func JWTMiddleware(jwksURL string) func(http.Handler) http.Handler {
keySet, _ := jwk.Fetch(context.Background(), jwksURL)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
token, err := jwt.Parse([]byte(tokenStr), jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithAudience("my-api"),
)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user", token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Audience(aud)クレームの活用
サービス間でトークンを伝播する際は、aud クレームでトークンの意図された受信者を限定します。
ユーザー → BFF: aud=["bff-service"]
BFF → User Service: aud=["user-service"] (トークン交換または新しいトークンの発行)
BFF → Order Service: aud=["order-service"]
各サービスは、自分の aud の値がトークンに含まれているかを必ず検証しなければなりません。これにより、User Service 用のトークンが Order Service で悪用されるのを防ぎます。
Service Mesh(Istio)mTLS
サービス間の通信は Istio の mTLS でトランスポート層のセキュリティを確保し、JWT はアプリケーション層の認可に使います。
# Istio: RequestAuthentication + AuthorizationPolicy
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-auth
spec:
jwtRules:
- issuer: 'https://idp.example.com'
jwksUri: 'https://idp.example.com/.well-known/jwks.json'
forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
spec:
rules:
- from:
- source:
requestPrincipals: ['*']
when:
- key: request.auth.claims[aud]
values: ['order-service']
セキュリティトレードオフ総合
| 脅威 | Cookie専用 | JWT専用(localStorage) | ハイブリッド(BFF) |
|---|---|---|---|
| XSS | HttpOnly により安全 | 非常に脆弱 (トークン窃取可能) | HttpOnly クッキーにより安全 |
| CSRF | 脆弱 (SameSiteで緩和) | 安全 (クッキー未使用) | SameSite + CSRF トークンで防御 |
| Token Theft | セッション ID のみ露出 | AT+RT ともに露出の危険 | トークンはサーバーにのみ存在 |
| Replay Attack | セッションバインディング | AT 窃取時に再利用が可能 | 短命の AT + サーバー検証 |
| Session Fixation | ログイン後のセッション再生成で防御 | 該当なし | セッション再生成で防御 |
| トークンの即時無効化 | セッション削除により可能 | 不可 (期限切れまで待つ) | セッション+ブラックリストで可能 |
Defense in Depth の原則: 単一の防御線に依存しません。HttpOnly クッキー + SameSite + CSRF Token + CSP (Content Security Policy) + 短命トークン + Token Rotation を何重にも適用します。
フレームワーク別要約比較表
| 項目 | Spring Boot | Django | React (SPA) | Next.js (App Router) |
|---|---|---|---|---|
| トークン保存 | サーバーセッション (HttpSession) | サーバーセッション (DB/Redis) | メモリ変数 | サーバーセッション (NextAuth) |
| JWT検証 | spring-security-oauth2-resource-server | PyJWT / djangorestframework-simplejwt | 不要 (BFF パターン時) | jose ライブラリ (サーバーサイド) |
| ミドルウェア | SecurityFilterChain | DjangoMiddleware | Axios インターセプタ | middleware.ts |
| CSRF防御 | CsrfFilter (自動) | CsrfViewMiddleware (自動) | クッキー未使用なら不要 | CSRF Token を手動で実装 |
| ログアウト | OidcClientInitiatedLogoutSuccessHandler | mozilla-django-oidc logout view | メモリのトークン削除 | signOut() + IdP ログアウト |
| セッションストア | Redis (Spring Session) | Redis (django-redis) | 該当なし | Redis / DB |
統合チェックリスト
SSO + ハイブリッド認証を実装する際に必ず確認すべき項目です。
- OIDC Discovery endpoint による IdP 設定の自動読み込み
- Authorization Code Flow + PKCE の適用
- ID Token の検証:
iss、aud、exp、nonceの各クレームを確認 - Access Token をブラウザに露出させない (BFF パターン)
- Refresh Token は HttpOnly Secure クッキーまたはサーバーセッションに保存
- Refresh Token Rotation の有効化 + Reuse Detection の実装
- Access Token の寿命を 15 分以下に設定
- セッションクッキー:
HttpOnly、Secure、SameSite=Lax、__Host-接頭辞 - CSRF への防御: SameSite クッキー + Double Submit Cookie または Synchronizer Token
- CORS の設定:
credentials: trueのときは origin を明示的に指定 (*の使用は禁止) - JWKS のキャッシュと自動ローテーションへの対応
- グローバルログアウト (Back-Channel Logout を推奨)
- Token Introspection またはブラックリストによる即時無効化
- サービス間のトークン伝播時に
audクレームを検証 - CSP (Content-Security-Policy) ヘッダーの設定
- HTTPS 専用 (HTTP でのアクセスを遮断)
- ログイン失敗時の Rate Limiting の適用
- 機微なログのマスキング (トークンの値をログに残さない)
よくあるバグと誤解
1. 「OAuth 2.0 は認証プロトコルだ」 — 違います
OAuth 2.0 は 認可 (Authorization) のフレームワークです。ユーザーが誰であるかを標準として知らせるものではありません。認証が必要なら OIDC を使う必要があります。Access Token だけで「ログイン」を実装すると、セキュリティ上の穴が生まれます。
2. 「JWT は常に stateless だ」 — 現実は違います
理論上 JWT は stateless ですが、実際のプロダクションでは ブラックリスト、セッションストア、Token Introspection といった stateful な要素がほぼ必須になります。「Pure Stateless JWT」は即時の無効化ができないため、セキュリティ事故への対応が難しくなります。
3. Access Token を localStorage に保存してもよい?
絶対にしないでください。XSS 攻撃が一度成立するだけでトークンが窃取されます。BFF パターンを使ってトークンをサーバーに保管し、ブラウザには HttpOnly のセッションクッキーだけを渡してください。
4. Refresh Token に長い有効期限を設定するだけで安全?
Refresh Token は 窃取されると長期間にわたって悪用される おそれがあります。必ず Token Rotation + Reuse Detection を適用し、可能であればデバイス/IP のバインディングも追加してください。
5. CORS の設定で Access-Control-Allow-Origin: * を使う?
credentials: true と併せてワイルドカード (*) を使うと、ブラウザがリクエストを拒否します。正確な origin を明示する必要があります。それだけでなく、ワイルドカードの origin はセキュリティ上も危険です。
6. SameSite=Strict にすれば最も安全?
SameSite=Strict は、外部サイトからリンク経由で入ってきたときにもクッキーを送らないため、SSO のユーザー体験を大きく損ないます。ほとんどの場合は SameSite=Lax が適切です。
7. ID Token を API 呼び出しに使う?
ID Token は クライアントアプリがユーザー情報を確認するためのものです。API の呼び出しには必ず Access Token を使ってください。ID Token の aud はクライアントアプリであって、API サーバーではありません。
参考資料
- OpenID Connect Core 1.0 Specification
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - OAuth 2.0 Bearer Token Usage
- RFC 7519 - JSON Web Token (JWT)
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- OpenID Connect RP-Initiated Logout
- OpenID Connect Back-Channel Logout
- Keycloak Documentation - Securing Applications
- Auth0 - Token Best Practices
- Auth0 - Refresh Token Rotation
- OWASP - Session Management Cheat Sheet
- OWASP - JSON Web Token Cheat Sheet
- Spring Security OAuth2 Resource Server Reference
- NextAuth.js Documentation
- Mozilla Django OIDC Documentation
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- Istio Security - Request Authentication