MENU

問い合わせ


    【Claude CodeとAWSで作るAIエージェントの土台 第5回】トークンで役割を判定し、Claude Code の環境に持たせる(Network secrets)

    前回は、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)

    1. claude.ai/code の新しいセッションの画面で、入力欄の下の行にある環境の選択を開き、「New environment」を選ぶ
    2. 「Environment variables」に TH_API_URL=https://api.example.com を入れる(トークンはここに書かない)
    3. 「Add environment」で保存する
    4. 作った環境の設定を開き(動いているセッションでは、タイトルの横の印から「Edit cloud environment」)、「Network secrets」の「Add secret」を選ぶ
    5. 「Secret type」は「Bearer」、「Allowed websites」に API のホスト(api.example.com)、値に発行したトークンを入れて「Add」で保存する
    6. 新しいセッション を始めて、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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      目次