Client Credentials でサービス間通信を認証する — マイクロサービス基盤での設計と実装
OAuth 2.0 の Client Credentials Grant をマイクロサービス間通信に適用する方法。トークンの中身、スコープ設計、キャッシュ戦略、OIDC Provider への組み込み方を実務レベルで整理。
はじめに
マイクロサービスが増えると「サービス A がサービス B の API を叩く」場面が必然的に出てきます。このとき「サービス A は本物か?」「どの API まで呼んでいいのか?」を制御するのが Client Credentials Grant です。
ユーザーが介在しない、マシン対マシンの認証に特化したフローで、他の OAuth フローとは性質が大きく異なります。
他のフローとの決定的な違い
| Authorization Code | Client Credentials | |
|---|---|---|
| 誰の権限? | ユーザーの権限 | サービス自体の権限 |
| ブラウザ | 必要(リダイレクト) | 不要 |
| 認可コード | ある | ない |
| JWT の sub | ユーザー ID | サービス ID |
| リクエスト数 | 2(認可 → トークン交換) | 1(トークン直接取得) |
フロー
サービス A
│
│ POST /oidc/token
│ {
│ grant_type: "client_credentials",
│ client_id: "service-a",
│ client_secret: "xxx",
│ scope: "billing:read usage:write"
│ }
│
▼
OIDC Provider
│
│ 1. client_id + secret を検証
│ 2. 要求された scope が許可されてるか確認
│ 3. Access Token を発行
│
▼
サービス A
│
│ GET /api/orgs/org-123/plan
│ Authorization: Bearer eyJhbGciOi...
│
▼
サービス B(rellf-billing 等)
│
│ 1. JWT の署名検証(JWKS)
│ 2. scope に "billing:read" があるか確認
│ 3. レスポンスを返す
│
▼
サービス A
HTTP リクエスト 1 本でトークンが返る。ブラウザもリダイレクトもありません。
トークンの中身
{
"iss": "https://auth.example.com",
"sub": "service-a",
"aud": "https://auth.example.com",
"exp": 1712800000,
"iat": 1712796400,
"scope": "billing:read usage:write",
"token_use": "access",
"client_id": "service-a"
}
| クレーム | 意味 |
|---|---|
sub | サービスの識別子(ユーザー ID ではない) |
scope | このサービスに許可された操作 |
client_id | クライアント ID(sub と同じことが多い) |
ID Token は発行しないのが一般的です。ID Token は「ユーザーは誰か」を表すものなので、ユーザー不在の Client Credentials では意味がありません。
スコープ設計
Client Credentials の肝は スコープでサービスごとのアクセス範囲を制御することです。
スコープの命名規則
{リソース}:{操作}
例:
| スコープ | 意味 |
|---|---|
billing:read | 課金情報の読み取り |
billing:write | 課金情報の書き込み |
usage:write | 使用量の記録 |
authz:check | 認可判定の実行 |
users:read | ユーザー情報の読み取り |
サービスごとのスコープ割り当て
service: cases-api
allowed_scopes: [billing:read, usage:write, authz:check]
service: admin-batch
allowed_scopes: [billing:read, billing:write, users:read]
service: notification-worker
allowed_scopes: [users:read]
最小権限の原則で、各サービスに必要なスコープだけを割り当てます。
マルチプロダクト基盤での具体例
┌─────────────┐ Client Credentials ┌──────────────┐
│ cases-api │──── billing:read ────────→│ rellf-billing │
│ (プロダクト) │──── authz:check ─────────→│ rellf-authz │
└─────────────┘ └──────────────┘
│
│ Authorization Code(ユーザーのリクエスト)
│
▼
┌─────────────┐
│ ブラウザ │
└─────────────┘
1 つのプロダクトが 2 種類のトークンを使い分けます:
| トークン | 取得方法 | 用途 |
|---|---|---|
| ユーザーの JWT | Authorization Code | 「ユーザーの代わりに」操作 |
| サービスの JWT | Client Credentials | 「サービスとして」他サービスの API を呼ぶ |
いつどちらを使うか
[ユーザーがリクエスト]
│
├─ プラン情報を取得(サービスとして billing API を叩く)
│ → Client Credentials トークンを使う
│
├─ 認可判定(サービスとして authz API を叩く)
│ → Client Credentials トークンを使う
│
└─ ユーザー情報を返す
→ ユーザーの JWT から取得
プラン情報や認可判定は「サービス A がサービス B に聞く」話なので、ユーザーのトークンではなくサービスのトークンを使います。
実装例
OIDC Provider 側(トークン発行)
func (h *TokenHandler) handleClientCredentials(c *gin.Context) {
clientID := c.PostForm("client_id")
clientSecret := c.PostForm("client_secret")
requestedScope := c.PostForm("scope")
// クライアント認証
client, err := h.clients.ValidateSecret(clientID, clientSecret)
if err != nil {
c.JSON(401, gin.H{"error": "invalid_client"})
return
}
// Client Credentials が許可されたクライアントか
if !client.AllowsGrantType("client_credentials") {
c.JSON(400, gin.H{"error": "unauthorized_client"})
return
}
// 要求されたスコープが許可されてるか
scopes := filterAllowedScopes(requestedScope, client.AllowedScopes)
// Access Token 発行
token, err := h.issuer.SignAccessToken(clientID, scopes, h.issuer.Issuer())
if err != nil {
c.JSON(500, gin.H{"error": "server_error"})
return
}
c.JSON(200, gin.H{
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600,
"scope": strings.Join(scopes, " "),
})
}
クライアント側(トークン取得 + API 呼び出し)
type ServiceClient struct {
tokenURL string
clientID string
clientSecret string
scopes []string
cachedToken string
tokenExpiry time.Time
mu sync.Mutex
}
func (c *ServiceClient) GetToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
// キャッシュが有効ならそれを返す
if c.cachedToken != "" && time.Now().Before(c.tokenExpiry) {
return c.cachedToken, nil
}
// 新しいトークンを取得
resp, err := http.PostForm(c.tokenURL, url.Values{
"grant_type": {"client_credentials"},
"client_id": {c.clientID},
"client_secret": {c.clientSecret},
"scope": {strings.Join(c.scopes, " ")},
})
// ...
c.cachedToken = tokens.AccessToken
c.tokenExpiry = time.Now().Add(time.Duration(tokens.ExpiresIn-60) * time.Second)
return c.cachedToken, nil
}
API 側(トークン検証 + スコープチェック)
func RequireScope(required string) gin.HandlerFunc {
return func(c *gin.Context) {
claims := getClaims(c)
scopes := strings.Split(claims["scope"].(string), " ")
for _, s := range scopes {
if s == required {
c.Next()
return
}
}
c.JSON(403, gin.H{"error": "insufficient_scope", "required": required})
c.Abort()
}
}
// ルーティング
api.GET("/orgs/:id/plan", RequireScope("billing:read"), handler.GetPlan)
api.POST("/orgs/:id/usage", RequireScope("usage:write"), handler.RecordUsage)
キャッシュ戦略
Client Credentials トークンは毎リクエスト取得する必要はありません。
トークン有効期限: 1 時間
キャッシュ: 有効期限の 1 分前まで使い回す
→ 1 時間に 1 回しかトークンエンドポイントを叩かない
注意点
- プロセス内キャッシュで十分(Redis 等は不要)
- Lambda の場合、コンテナが再利用される間はキャッシュが効く
- 複数プロセス/コンテナがあっても、各自がキャッシュすればよい(OIDC Provider の負荷は低い)
セキュリティ上の考慮
client_secret の管理
Client Credentials は client_secret が漏れると即座にトークンが取れてしまうため、厳重な管理が必要です。
| 推奨 | 非推奨 | |
|---|---|---|
| 保存場所 | SSM Parameter Store / Secrets Manager | 環境変数直書き、コード内 |
| ローテーション | 定期的に変更 | 固定 |
| ネットワーク | VPC 内 or HTTPS | 平文通信 |
スコープの粒度
スコープが粗すぎると最小権限にならず、細かすぎると管理が破綻します。
粗すぎ: scope=admin → 何でもできてしまう
細かすぎ: scope=billing:org:123:plan:read → 管理不能
ちょうどいい: scope=billing:read usage:write
リソース種別 × 操作の粒度が実用的です。
トークンの有効期限
Client Credentials のトークンは短めにするのが推奨です。
| 有効期限 | ユースケース |
|---|---|
| 5 分 | 高セキュリティ(金融系) |
| 1 時間 | 一般的 |
| 24 時間 | 内部バッチ処理 |
短くするほど安全ですが、トークン取得の頻度が上がります。1 時間がバランス良いです。
Authorization Code との共存
実際のシステムでは、1 つの OIDC Provider が両方のフローを提供します。
func (h *TokenHandler) HandleToken(c *gin.Context) {
grantType := c.PostForm("grant_type")
switch grantType {
case "authorization_code":
h.handleAuthorizationCode(c)
case "client_credentials":
h.handleClientCredentials(c)
case "refresh_token":
h.handleRefreshToken(c)
default:
c.JSON(400, gin.H{"error": "unsupported_grant_type"})
}
}
/oidc/token エンドポイントは同じで、grant_type パラメータで分岐するだけです。
クライアント登録の違い
| Authorization Code 用 | Client Credentials 用 | |
|---|---|---|
| client_type | confidential / public | confidential のみ |
| redirect_uri | 必須 | 不要 |
| allowed_grant_types | authorization_code, refresh_token | client_credentials |
| allowed_scopes | openid, email, profile | billing:read, authz:check 等 |
| ユーザー操作 | あり(ログイン画面) | なし |
// Client Credentials 用のクライアント登録例
{
"client_id": "cases-api",
"client_secret": "xxx",
"client_type": "confidential",
"allowed_grant_types": ["client_credentials"],
"allowed_scopes": ["billing:read", "usage:write", "authz:check"],
"description": "Cases プロダクトのバックエンド"
}
まとめ
- Client Credentials はユーザー不在のサービス間通信に使う OAuth フロー
- HTTP 1 リクエストでトークン取得、ブラウザもリダイレクトもない
- JWT の
subはユーザーではなくサービス自体の識別子 - スコープでサービスごとのアクセス範囲を制御(最小権限の原則)
- トークンはプロセス内キャッシュで使い回す(有効期限 1 分前まで)
client_secretの管理は SSM / Secrets Manager で厳重に- OIDC Provider の
/oidc/tokenはgrant_typeで Authorization Code と共存する