深夜2時、誰も見ていない時間帯にスケジュール実行が走り、API を叩いて処理を終える——この「無人で回している時間」が、実はいちばん神経を使う部分です。私自身、複数のサイトの記事更新を時間をずらして自動で回していますが、機能の華やかさよりも「置きっぱなしにしている API キーが、いつか何かの拍子に漏れていないか」という不安のほうが、ずっと頭に残ります。
2026年6月28日のアップデートで、Claude Code は静的な API キーを WIF(Workload Identity Federation) の短命・スコープ付き資格情報へ置き換える方向を示しました。OIDC 準拠の ID プロバイダと連携し、リクエスト時に発行される使い捨ての資格情報を使う仕組みです。ここでは、その発想を個人開発の小さな無人運用に翻訳して、何をどう移すと被害範囲が縮むのかを具体的に書いていきます。
静的キーは「漏れた瞬間」ではなく「漏れていた期間」が怖い
ANTHROPIC_API_KEY を環境変数や .env に置いて、cron やスケジューラから読ませる——これは動きますし、最初はそれで十分です。問題は、このキーがいつまでも有効であることにあります。
静的キーの本当の怖さは、漏れた瞬間そのものではありません。漏れていたことに気づくまでの「期間」です。CI のログにうっかり出力されていた、バックアップに含まれていた、古いマシンのシェル履歴に残っていた——どれも、発覚するのは数週間後だったりします。その間ずっと、フルスコープの有効なキーが外を歩いている状態になります。
無人運用ではこの構造がさらに効いてきます。人が画面を見ていないので、不審なリクエストの急増にすぐ気づけません。気づく頃には、それなりの回数が叩かれた後です。だからこそ「漏れても有効期間が短い」「漏れても使える範囲が狭い」という二つの性質が、運用の安心感を大きく変えます。
WIF は「鍵を配る」のではなく「その都度交換する」
WIF の発想を一言でいうと、長持ちする秘密(静的キー)を配って回るのをやめて、信頼できる身元証明をその都度、短命の資格情報と交換することです。
流れはおおむね三段階になります。まず、実行環境が OIDC 準拠の ID プロバイダから自分の身元を示すトークン(ID トークン)を受け取ります。次に、そのトークンを資格情報の発行元へ提示し、引き換えに有効期間の短い・スコープを絞った資格情報を受け取ります。最後に、その短命資格情報で実際の API を叩きます。処理が終わって資格情報が期限切れになれば、それはもう何の役にも立ちません。
静的キーと短命資格情報の違いを並べると、運用上の意味がはっきりします。
| 観点 | 静的 API キー | WIF の短命資格情報 |
|---|---|---|
| 有効期間 | 失効させるまで無期限 | 数分〜1時間程度で自動失効 |
| 保管場所 | 環境変数・.env・シークレットストア | 原則メモリ上のみ。永続保存しない |
| 漏えい時の被害 | 気づくまでフルスコープで悪用可能 | 期限切れ後は無効。スコープ外も不可 |
| ローテーション | 手動または定期ジョブで差し替え | リクエストのたびに実質ローテーション |
| 身元の証明 | キーを持っていること=本人 | OIDC トークンで実行環境を検証 |
ポイントは右下です。静的キーは「持っていること」が本人証明のすべてなので、コピーされたら終わりです。WIF は「どの実行環境から来たか」を毎回検証するので、キー文字列だけ抜かれても、検証を通る環境がなければ交換が成立しません。
移行の最小単位は「キー直書き」を「取得関数」に変えること
いきなり全部を WIF に寄せる必要はありません。移行のいちばん小さな一歩は、コードの中で API キーを直接読んでいる箇所を、資格情報を取得する関数の呼び出しに差し替えることです。ここを関数の裏側に隠してしまえば、中身を静的キーから短命資格情報へ後から入れ替えても、呼び出し側は変わりません。
次のコードは、その「取得関数」を一つのクラスにまとめた例です。OIDC トークンを短命資格情報と交換し、有効期限が近づくまではメモリ上にキャッシュし、期限の手前で先回りして取り直します。エンドポイントや項目名は環境やプロバイダで変わるため、公式ドキュメントで実際のフィールド名を確認しながら埋めてください。
import os
import time
import threading
import requests
class ShortLivedCredentialProvider:
"""OIDC トークンを短命資格情報と交換し、期限手前で先回り更新する。
呼び出し側は get() を呼ぶだけ。中身が静的キーでも WIF でも、
インターフェースは変わらない。
"""
# 期限の何秒手前で「もう使わない」と判断するか(時計ずれ+処理時間の余白)
REFRESH_BUFFER_SEC = 120
def __init__(self, token_url: str, exchange_url: str, audience: str):
self._token_url = token_url # OIDC ID トークンの取得元
self._exchange_url = exchange_url # 短命資格情報との交換先
self._audience = audience # 交換先が期待する audience
self._lock = threading.Lock()
self._cached_value = None
self._expires_at = 0.0
def _fetch_id_token(self) -> str:
# 実行環境のメタデータサービス等から OIDC ID トークンを取得する。
# クラウドや CI ごとに取得方法が異なるため、ここは環境に合わせる。
resp = requests.get(
self._token_url,
params={"audience": self._audience},
headers={"Metadata-Flavor": "Google"}, # 例。プロバイダにより異なる
timeout=10,
)
resp.raise_for_status()
return resp.text.strip()
def _exchange(self, id_token: str) -> tuple[str, float]:
# ID トークンを提示して、短命・スコープ付きの資格情報を受け取る。
resp = requests.post(
self._exchange_url,
json={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"scope": "messages:write", # 必要最小限のスコープだけ要求する
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
# フィールド名はプロバイダの仕様に合わせて読み替える
value = data["access_token"]
ttl = float(data.get("expires_in", 900))
return value, time.time() + ttl
def get(self) -> str:
now = time.time()
# 期限の手前を過ぎていたら、ロックを取って更新する
if now >= self._expires_at - self.REFRESH_BUFFER_SEC:
with self._lock:
# ロック待ちの間に別スレッドが更新済みなら再利用
if now >= self._expires_at - self.REFRESH_BUFFER_SEC:
id_token = self._fetch_id_token()
self._cached_value, self._expires_at = self._exchange(id_token)
return self._cached_value
# 呼び出し側はこれだけ。キーの実体がどこから来るかを知らなくてよい
provider = ShortLivedCredentialProvider(
token_url=os.environ["OIDC_TOKEN_URL"],
exchange_url=os.environ["CREDENTIAL_EXCHANGE_URL"],
audience=os.environ["EXCHANGE_AUDIENCE"],
)
# Anthropic クライアントへ毎回「取りたて」の資格情報を渡す
from anthropic import Anthropic
def make_client() -> Anthropic:
return Anthropic(api_key=provider.get())このコードで効いているのは REFRESH_BUFFER_SEC の存在です。資格情報を「期限ちょうどまで使う」のではなく、期限の少し手前で使うのをやめる。長時間バッチでは、取得してから実際に API へ届くまでに時間差があり、サーバーとの時計のずれもあります。期限ぴったりまで粘ると、稀に「自分の時計では有効、相手から見ると失効」という事故が起きます。手前で切り上げる設計は、その境界事故を構造的に防ぐためのものです。
つまずきやすいのは、時計・スコープ・フォールバックの三つ
実際に移すと、つまずく場所はだいたい決まっています。
一つ目は時計のずれです。短命資格情報は有効期間が短いぶん、サーバーとのわずかな時刻差が顕在化しやすくなります。先ほどの余白に加えて、実行環境の NTP 同期が効いているかを一度確認しておくと、原因不明の 401 を未然に減らせます。
二つ目はスコープの取りすぎです。せっかく短命にしても、毎回フルスコープの資格情報を発行していたら、被害範囲を絞るという狙いが半分になります。自動処理が本当に必要とする操作だけを scope に書く。記事を投稿するだけのジョブに、課金や管理系の権限まで含めない。短命であることと、狭いことは別の防御で、両方そろって初めて効きます。
三つ目はフォールバックの方向です。資格情報の取得に失敗したとき、「とりあえず古い静的キーで動かす」という逃げ道を残すと、それは結局フルスコープの長命キーを生かし続けることになります。ここは深く考えずに失敗で止める——いわゆる deny by default に倒すほうが、無人運用では安全です。動き続けることより、被害を広げないことを優先します。秘密の露出面そのものを絞る考え方は、サンドボックスはコードを動かせても、認証ファイルは読ませない でも触れています。
まず一本のジョブから、取得関数の裏に隠す
全面移行をいきなり狙わず、いちばん事故っても傷が浅いジョブを一本選んで、そこだけ取得関数の裏に資格情報を隠してみてください。呼び出し側のコードが変わらないことを確認できれば、残りのジョブは同じ関数を共有させるだけで順に寄せていけます。
そのうえで、もし過去にキーを履歴へ載せてしまった経験があるなら、移行と並行して APIキーを誤って git commit してしまった時の緊急対応手順 で棚卸ししておくと、古い静的キーを安心して無効化できます。短命資格情報への移行は、その「古い鍵を捨てる」決断を、ずっと軽くしてくれます。
同じように無人で何かを回している方の参考になれば嬉しいです。お読みいただきありがとうございました。