前回は、API の骨組みを ECS Fargate と ALB で動かす構成を作りました。

今回は、この連載の中心の仕組みです。API がトークンから「この呼び出しはどの役割か」を判定し、Claude Code の環境にそのトークンを持たせます。
「AI にトークンを渡したら、AI がそれをどこかに書き出してしまわない?」
結論から言うと、トークンは Claude Code の環境の「Network secrets」に入れます。Network secrets の値は、セッションの中の AI からは見えず、指定した宛先への通信にだけ自動で付きます。環境変数に入れると、セッションの中から読めてしまうので使いません。API の側では、トークンそのものは保存せず、元に戻せない形(ハッシュ)だけを持ちます。
トークンの決まり
| 項目 | 決めたこと |
|---|---|
| 形 | th_<役割>_<推測できない32バイトの乱数>。先頭を見れば当社のトークンだと分かる(第8回の WAF で使う) |
| 保存 | DynamoDB の表には SHA-256 のハッシュ(=元に戻せない変換をした値)だけ。トークンそのものはどこにも残さない |
| 記録するもの | 役割、作った日時、期限、失効したか。何ができるかは記録しない(第1回の app/roles.py で決める) |
| 期限 | サンプルは既定で90日(当社の社内基盤は365日) |
| 発行と失効 | 人が CloudShell で行う。AI には実行させない |
ハッシュだけを持っておけば、万一表の中身が漏れても、そこからトークンを作り直すことはできません。
トークンから役割を引く(app/auth.py)
トークンは Authorization: Bearer <トークン> の形で受け取ります。
# app/auth.py
"""トークン → 役割。
- トークンは `Authorization: Bearer <トークン>` で受け取る。Claude Code の環境では Network secrets が付ける
- 保存するのはトークンのハッシュ(SHA-256。元に戻せない変換)だけ。トークンそのものは残さない
- 期限切れ・失効したトークンは断る
"""
import hashlib
import json
import time
from dataclasses import dataclass
from pathlib import Path
from fastapi import HTTPException, Request
@dataclass(frozen=True)
class Principal:
role: str
token_id: str # ハッシュの先頭12文字。記録に使う(トークンそのものではない)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
class TokenStore:
"""DynamoDB の表(本番・確認用)か、手元のファイル(試すとき)からトークンを引く。"""
def __init__(self, table_name: str = '', local_file: str = '', table=None):
self._table = table
if self._table is None and table_name:
import boto3
self._table = boto3.resource('dynamodb').Table(table_name)
self._local = Path(local_file) if (local_file and not self._table) else None
def get(self, token_hash: str):
if self._table is not None:
return self._table.get_item(Key={'token_hash': token_hash}).get('Item')
if self._local and self._local.exists():
return json.loads(self._local.read_text(encoding='utf-8')).get(token_hash)
return None
def lookup(self, token: str):
h = hash_token(token)
item = self.get(h)
if not item or item.get('revoked'):
return None
expires = int(item.get('expires') or 0)
if expires and expires < time.time():
return None
return Principal(role=item['role'], token_id=h[:12])
def get_principal(request: Request) -> Principal:
header = request.headers.get('authorization', '')
token = header[7:].strip() if header.lower().startswith('bearer ') else ''
principal = request.app.state.tokens.lookup(token) if token else None
if principal is None:
raise HTTPException(401, 'トークンがない・違う・期限切れです')
request.state.principal = principal
return principal
TokenStoreは、AWS では DynamoDB の表から、手元で試すときはファイルから、ハッシュでトークンの情報を引きます- 失効している、期限が切れている、そもそも無い、のどれでも
Noneを返し、API は 401(=認証に失敗した)を返します token_idはハッシュの先頭12文字です。記録や失効のときにトークンを指す名前として使い、トークンそのものは記録に出しません
API に「自分の役割」を足す
app/main.py に次の変更を加えます(+ が追加、- が削除)。
--- a/app/main.py
+++ b/app/main.py
@@ -1,6 +1,7 @@
"""渡された文章を WordPress に投稿するだけの中継 API(連載のサンプル)。
GET /healthz 起動確認(トークン不要)
+ GET /me 自分の役割
起動:uvicorn --factory app.main:create_app
"""
@@ -8,20 +9,26 @@
import json
from pathlib import Path
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI
+from .auth import Principal, TokenStore, get_principal
from .settings import Settings
-def create_app(settings: Settings | None = None) -> FastAPI:
+def create_app(settings: Settings | None = None, *, tokens=None) -> FastAPI:
settings = settings or Settings()
app = FastAPI(title='ai-agent-kiban')
app.state.settings = settings
app.state.sites = {k: v for k, v in json.loads(Path(settings.sites_file).read_text(encoding='utf-8')).items()
if not k.startswith('_')}
+ app.state.tokens = tokens or TokenStore(settings.tokens_table, settings.local_tokens_file)
@app.get('/healthz')
def healthz():
return {'ok': True, 'stage': settings.stage}
+ @app.get('/me')
+ def me(p: Principal = Depends(get_principal)):
+ return {'role': p.role}
+
return app
Depends(get_principal) を付けた呼び出しは、トークンが正しくないと中身が動く前に 401 で止まります。/healthz には付けないので、ALB のヘルスチェックはトークンなしで通ります。
テストでは DynamoDB の代わりに、決まったトークンのハッシュだけを持つ偽物を使います。tests/conftest.py に次の変更を加えます。
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,15 +1,38 @@
import pytest
from fastapi.testclient import TestClient
+from app.auth import TokenStore, hash_token
from app.main import create_app
from app.settings import Settings
+
+TOKENS = {
+ 'th_ok': {'role': 'blog-sample'},
+ 'th_other': {'role': 'blog-other'},
+ 'th_revoked': {'role': 'blog-sample', 'revoked': True},
+ 'th_expired': {'role': 'blog-sample', 'expires': 1},
+}
+
+
+class FakeTokens(TokenStore):
+ """DynamoDB の表の代わり。TOKENS をハッシュにして持つ(本物と同じく、トークンそのものは持たない)"""
+
+ def __init__(self):
+ super().__init__()
+ self._items = {hash_token(t): {'token_hash': hash_token(t), **v} for t, v in TOKENS.items()}
+
+ def get(self, token_hash):
+ return self._items.get(token_hash)
def make(stage):
settings = Settings(stage=stage, sites_file='config/sites.json')
- return TestClient(create_app(settings))
+ return TestClient(create_app(settings, tokens=FakeTokens()))
@pytest.fixture
def prod():
return make('prod')
+
+
+def bearer(token):
+ return {'Authorization': f'Bearer {token}'}
tests/test_api.py に、トークンを確かめるテストを足します。
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,2 +1,12 @@
+from conftest import bearer
+
+
def test_healthz_needs_no_token(prod):
assert prod.get('/healthz').json() == {'ok': True, 'stage': 'prod'}
+
+
+def test_tokens_are_required_and_checked(prod):
+ assert prod.get('/me').status_code == 401
+ for t in ('nope', 'th_revoked', 'th_expired'):
+ assert prod.get('/me', headers=bearer(t)).status_code == 401, t
+ assert prod.get('/me', headers=bearer('th_ok')).json() == {'role': 'blog-sample'}
トークンの発行と失効(人が実行する)
トークンを発行・失効するコマンドです。tools フォルダを作って置きます。
mkdir -p tools
tools/issue_token.py(1行目は実行用の指定なので、パスは書いていません)
#!/usr/bin/env python3
"""トークンの発行・失効(人が実行する。AI には実行させない)。
# AWS(DynamoDB の表)に登録する
python3 tools/issue_token.py issue --table <表の名前> --role blog-sample [--days 90]
python3 tools/issue_token.py revoke --table <表の名前> --id <ハッシュの先頭12文字>
# 手元で試すとき(ファイルに登録する)
python3 tools/issue_token.py issue --local .tokens.local.json --role blog-sample
発行したトークンは1回だけ表示する。Claude Code の環境の Network secrets に登録し、
ほかの場所(チャット・ファイル・メモ)には残さない。
"""
import argparse
import json
import secrets
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.auth import hash_token # noqa: E402
from app.roles import ROLE_SITES # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument('cmd', choices=['issue', 'revoke'])
where = ap.add_mutually_exclusive_group(required=True)
where.add_argument('--table')
where.add_argument('--local')
ap.add_argument('--role', choices=sorted(ROLE_SITES))
ap.add_argument('--days', type=int, default=90)
ap.add_argument('--id')
a = ap.parse_args()
if a.table:
import boto3
table = boto3.resource('dynamodb').Table(a.table)
put = lambda item: table.put_item(Item=item) # noqa: E731
scan = lambda: table.scan()['Items'] # noqa: E731
revoke = lambda h: table.update_item(Key={'token_hash': h}, UpdateExpression='SET revoked = :t', # noqa: E731
ExpressionAttributeValues={':t': True})
else:
path = Path(a.local)
data = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {}
def put(item):
data[item['token_hash']] = item
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
scan = lambda: list(data.values()) # noqa: E731
def revoke(h):
data[h]['revoked'] = True
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
if a.cmd == 'issue':
if not a.role:
sys.exit('--role を指定してください')
token = f"th_{a.role.replace('-', '_')}_{secrets.token_urlsafe(32)}"
h = hash_token(token)
put({'token_hash': h, 'role': a.role, 'created': int(time.time()),
'expires': int(time.time()) + a.days * 86400, 'revoked': False})
print(f'役割: {a.role} ID: {h[:12]} 期限: {a.days}日')
print('トークン(この1回だけ表示。Network secrets に登録したら画面を閉じる):')
print(token)
else:
hits = [i for i in scan() if i['token_hash'].startswith(a.id or '-')]
if len(hits) != 1:
sys.exit(f'ID {a.id} に一致するトークンが {len(hits)} 件です')
revoke(hits[0]['token_hash'])
print(f"失効しました: {hits[0]['token_hash'][:12]} ({hits[0]['role']})")
if __name__ == '__main__':
main()
- 発行したトークンは、この1回だけ表示 します。Claude Code の環境の Network secrets に入れたら、画面を閉じます。チャット・ファイル・メモには残しません
--roleにはapp/roles.pyにある役割だけを指定できます- 失効は、トークンそのものではなく ID(ハッシュの先頭12文字)で指定します
当社では、このコマンドを人が CloudShell で実行しています。CLAUDE.md にも「通行証(トークン)を発行しない・扱わない」と書き、AI には実行させません。
セッションから API を呼ぶコマンド
Claude Code のセッションの中で、自分の役割を確かめる小さなコマンドです。
tools/agent.py(1行目は実行用の指定なので、パスは書いていません)
#!/usr/bin/env python3
"""Claude Code のセッションから API を呼ぶ小さなコマンド。
python3 tools/agent.py me 自分の役割
接続先は環境変数 TH_API_URL。トークンは Claude Code の環境の Network secrets が付ける。
手元で試すときだけ、環境変数 TH_TOKEN を使う。
"""
import argparse
import os
import sys
import urllib.error
import urllib.request
def get(path):
base = os.environ.get('TH_API_URL', '').rstrip('/')
if not base:
sys.exit('TH_API_URL が設定されていません')
req = urllib.request.Request(base + path)
if os.environ.get('TH_TOKEN'):
req.add_header('Authorization', f"Bearer {os.environ['TH_TOKEN']}")
try:
with urllib.request.urlopen(req, timeout=30) as res:
return res.read().decode('utf-8')
except urllib.error.HTTPError as e:
sys.exit(f'{e.code}: {e.read().decode("utf-8", "replace")}')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('cmd', choices=['me'])
print(get('/' + ap.parse_args().cmd))
if __name__ == '__main__':
main()
接続先は環境変数 TH_API_URL から読みます。トークンは Network secrets が通信に付けるので、このコマンドはトークンを持ちません。手元で試すときだけ、環境変数 TH_TOKEN を使います。
Claude Code の環境を作る
役割ごとに、Claude Code on the web の環境(=セッションを動かす入れ物の設定)を作ります。手順は 2026年10月31日に公式のドキュメントで確かめたものです(画面の言語:英語)。
📰 出典:Configure cloud environments(Claude Code Docs)
- claude.ai/code の新しいセッションの画面で、入力欄の下の行にある環境の選択を開き、「New environment」を選ぶ
- 「Environment variables」に
TH_API_URL=https://api.example.comを入れる(トークンはここに書かない) - 「Add environment」で保存する
- 作った環境の設定を開き(動いているセッションでは、タイトルの横の印から「Edit cloud environment」)、「Network secrets」の「Add secret」を選ぶ
- 「Secret type」は「Bearer」、「Allowed websites」に API のホスト(
api.example.com)、値に発行したトークンを入れて「Add」で保存する - 新しいセッション を始めて、
python3 tools/agent.py meで役割が返ることを確かめる
環境変数に入れてはいけない理由
公式のドキュメントにもあるとおり、環境変数はセッションの中で動くコマンドから普通に読めます。AI が何かの拍子に環境変数を表示すれば、トークンが会話やファイルに残ってしまいます。
Network secrets の値は、セッションの環境変数にもファイルにも現れず、保存したあとは画面でも見られません。通信がセッションの外に出たところで、指定した宛先にだけ付けられます。GitHub への通信には付きません。
分かったこと
- Network secrets に入れた宛先は、ネットワークの設定(既定の「Trusted」のまま)にかかわらずつながります。許可する宛先に足さなくてかまいません
- 環境の設定を変えても、動いているセッションには届きません。確かめるときは新しいセッションを始めます
- 確認用と本番は別の環境にし、セッションを始めるときに選びます。接続先(
TH_API_URL)・Network secrets の宛先・トークンの発行元(確認用の表か本番の表か)の3つが同じ側にそろっていないと、401 になります。当社では、本番のトークンを入れた環境が確認用の URL を向いていて、しばらく悩みました
動作確認
手元で、トークンの発行から失効までを試します。
pip install -e ".[dev]"
ruff check .
pytest -q
python3 tools/issue_token.py issue --local .tokens.local.json --role blog-sample
表示されたトークンを控え、API を起動します。
STAGE=staging uvicorn --factory app.main:create_app --port 8080
別のターミナルで呼びます。<トークン> は控えたものに置き換えます。
curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/me
TH_API_URL=http://localhost:8080 TH_TOKEN=<トークン> python3 tools/agent.py me
python3 tools/issue_token.py revoke --local .tokens.local.json --id <表示されたID>
TH_API_URL=http://localhost:8080 TH_TOKEN=<トークン> python3 tools/agent.py me
401
{"role":"blog-sample"}
失効しました: <ID> (blog-sample)
401: {"detail":"トークンがない・違う・期限切れです"}
トークンなしは 401、発行したトークンでは役割が返り、失効させると 401 に戻れば完成です。テストは4件が通ります。.tokens.local.json は .gitignore に入っているので、git には入りません。
サンプルは、失効がすぐに効きます(呼び出しのたびに表を引くため)。当社の社内基盤では、表を引く回数を減らすために結果を60秒だけ覚えているので、失効が効くまで最大60秒かかります。
つまずきやすい点・セキュリティ上の注意
- トークンをチャットに貼らない:「動かないので見てほしい」とトークンを AI に貼ると、会話に残ります。困ったときは ID(ハッシュの先頭12文字)で話します
- 確認用と本番のトークンを分ける:確認用のトークンで本番に入れてしまうと、確認用で試したつもりの操作が本番に届きます。表をアカウントごとに分けておけば、別の表のトークンは 401 になります
- AI にトークンの発行をさせない:発行できる人がトークンを見られる人です。人が行う作業として残します
発注者向けメモ
AI エージェントや社内のツールに API を使わせるときは、次を確認しておきましょう。
- 「トークン(APIキー)は、どこに保存されますか。AI やツールの利用者から値が見えますか」
- 「サーバー側には、トークンそのものが保存されますか。ハッシュだけですか」
- 「トークンに期限はありますか。漏れたときに、そのトークンだけを止められますか」
- 「確認用と本番で、トークンは別ですか」
利用者や役割が多い、トークンの発行を申請と承認の流れにしたい、利用の記録を長く残したい、といった条件があると、工数が増えやすくなります。
まとめと次回予告
- API はトークンをハッシュにして表を引き、役割を判定する。トークンそのものはどこにも残さない
- トークンは Claude Code の環境の Network secrets に入れる。環境変数はセッションの中から読めるので使わない
- 接続先・Network secrets の宛先・トークンの発行元の3つを、確認用と本番でそろえる
次回は、AI への指示書(CLAUDE.md)を入口だけにし、役割ごとの指示書を API から配る 形を作ります。
この連載の記事一覧
この記事は連載「Claude CodeとAWSで作るAIエージェントの土台」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Claude CodeとAWSで作るAIエージェントの土台 第1回】なぜリポジトリをやめて、役割ごとの API にしたのか
- 【Claude CodeとAWSで作るAIエージェントの土台 第2回】AWS を4つのアカウントに分け、Google Workspace でログインする
- 【Claude CodeとAWSで作るAIエージェントの土台 第3回】GitHub Actions から AWS へ入る(OIDC)と、sub が番号付きになる落とし穴
- 【Claude CodeとAWSで作るAIエージェントの土台 第4回】ECS Fargate と ALB を NAT なしで小さく始める:証明書・DNS の落とし穴
- 【Claude CodeとAWSで作るAIエージェントの土台 第5回】トークンで役割を判定し、Claude Code の環境に持たせる(Network secrets)(この記事)
- 【Claude CodeとAWSで作るAIエージェントの土台 第6回】CLAUDE.md は入口だけにする:役割ごとの指示書を API で配る
- 【Claude CodeとAWSで作るAIエージェントの土台 第7回】既存のリポジトリから移す:秘密の値の検査と、WordPress への投稿の中継
- 【Claude CodeとAWSで作るAIエージェントの土台 第8回】守りと費用:401 の監視、WAF の回数制限、確認用はふだん止める









