AWS 2026年10月27日

AWS_PROFILEと環境変数の鍵が両方あると、CLIとNode.jsのSDKで署名する鍵が逆になる。偽のS3で4つの道具を数えたら、鍵が無いときに諦めるまでも0.01秒から13秒まで割れた

XECIN IAMNode.jsGo技術検証

先日、手元の S3 互換ストレージ(MinIO)を相手に検証用のスクリプトを回していたとき、Node.js のスクリプトが長い警告を出しました。

読むと「AWS_PROFILE と、環境変数の鍵(AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY)の両方が設定されている。この SDK は AWS_PROFILE のほうを使う」という趣旨です。MinIO 用の鍵を環境変数に入れたつもりが、端末に以前から入っていた AWS_PROFILE の設定で署名していたことになります。相手が MinIO だったので実害はありませんでしたが、接続先が本物の S3 だったら、意図しないアカウントに対して操作していたはずです。少しひやりとしました。

正直なところ、私は「環境変数の鍵がいちばん強い」とずっと思い込んでいました。運用を考えると、思い込みのまま手順書を書くのは危険です。そこで、AWS CLI・Python の boto3・Node.js と Go の SDK が、どの条件でどの鍵を使うのかを手元で数えることにしました。ついでに、鍵がどこにも無いときに何秒で諦めるのかも測っています。

偽の S3 に「どの鍵で署名したか」を返させる

測り方は単純です。AWS の署名(SigV4)では、Authorization ヘッダーに Credential=アクセスキーID/日付/リージョン/サービス/aws4_request が入ります。秘密鍵そのものは送られませんが、どのアクセスキー ID で署名したかは受け手から見えます。そこで、受け取ったアクセスキー ID をそのままバケット名にして返す偽の S3 を立てました。どの道具でも ListBuckets を 1 回呼べば、返ってきたバケット名で「どの鍵だったか」が分かります。

// fake-s3.mjs: 署名に使われたアクセスキーIDを記録し、バケット名にして返す
import http from "node:http";

http.createServer((req, res) => {
  // SigV4 の Authorization には "Credential=アクセスキーID/日付/リージョン/s3/aws4_request" が入る
  const found = (req.headers.authorization ?? "").match(/Credential=([^/]+)\//);
  const keyId = found ? found[1] : "unsigned";
  console.log(new Date().toISOString(), req.method, req.url, keyId);
  res.writeHead(200, { "content-type": "application/xml" });
  res.end(`<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Owner><ID>fake</ID><DisplayName>fake</DisplayName></Owner>
  <Buckets><Bucket>
    <Name>signed-by-${keyId.toLowerCase()}</Name>
    <CreationDate>2026-10-27T00:00:00.000Z</CreationDate>
  </Bucket></Buckets>
</ListAllMyBucketsResult>`);
}).listen(18125, "127.0.0.1");

認証情報のファイルも本物とは分けました。AWS_CONFIG_FILE と AWS_SHARED_CREDENTIALS_FILE で作業用のファイルを指し、中には偽の鍵だけを入れています(default と dev の 2 つのプロファイル)。接続先は AWS_ENDPOINT_URL で偽の S3 に固定し、後で触れる IMDS への問い合わせも止めた状態で、次の 4 つを当てました。

  • AWS CLI 2.22.7(Windows)
  • boto3 1.43.119(Python 3.13.5)
  • AWS SDK for JavaScript v3(@aws-sdk/client-s3 3.1157.0、Node.js 22.14.0)
  • AWS SDK for Go v2(config v1.33.11・service/s3 v1.114.5、Go 1.25 のコンテナ)

費用の話もしておきます。今回、偽の S3 が受けた署名付きのリクエストは全部で 28 回でした。東京リージョンの S3 は LIST 系のリクエストが 1,000 回で 0.0047 ドル(2026-10-15 時点の価格表)なので、本物の S3 に送っても 0.0001 ドルほどです。費用だけならどちらでも変わりません。それでも偽の S3 を選んだのは、取り違えた鍵が本物のアカウントに届かないからです。「どの鍵が使われるか分からない」ことを確かめる実験ほど、本物には向けないほうがよいと考えています。

両方あるときに AWS_PROFILE を選んだのは Node.js だけだった

5 つの条件で、4 つの道具がどの鍵で署名したかを並べます。D は、コマンドの --profile dev や、コードでプロファイル名を渡した場合です(boto3 は Session(profile_name="dev")、Node.js は new S3Client({ profile: "dev" })、Go は config.WithSharedConfigProfile("dev"))。

条件AWS CLIboto3Node.js の SDK v3Go の SDK v2
A 環境変数の鍵だけ環境変数環境変数環境変数環境変数
B AWS_PROFILE=dev だけdevdevdevdev
C 環境変数の鍵 + AWS_PROFILE=dev環境変数環境変数dev(警告あり)環境変数
D 環境変数の鍵 + コマンドやコードで dev を指定devdevdev(警告あり)dev
E どちらも無し(ファイルの default)defaultdefaultdefaultdefault

割れたのは C の行だけです。CLI・boto3・Go は環境変数の鍵、Node.js の SDK だけが AWS_PROFILE の dev で署名しました。冒頭の警告は、まさにこの条件で出ていたものです。

# C: 環境変数の鍵と AWS_PROFILE=dev を両方置いて、4 つの道具で ListBuckets を 1 回ずつ(コマンドは抜粋)
export AWS_ACCESS_KEY_ID=AKIAFAKEENVIRONMENT0 AWS_SECRET_ACCESS_KEY=fake-env-secret
export AWS_PROFILE=dev
aws s3api list-buckets --query 'Buckets[0].Name' --output text
python list.py          # boto3
node list.mjs           # Node.js の SDK v3
./credtest              # Go の SDK v2(コンテナに同じ環境変数を渡す)
# 実行結果:
# signed-by-akiafakeenvironment0                                     ← CLI
# signed-by-akiafakeenvironment0 method=env                          ← boto3
# signed-by-akiafakeprofiledev00 source={"CREDENTIALS_PROFILE":"n"}  ← Node.js
# signed-by-akiafakeenvironment0 source=EnvConfigCredentials         ← Go
# Node.js が標準エラーに出した警告(抜粋):
#   Multiple credential sources detected:
#   Both AWS_PROFILE and the pair AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY static credentials are set.
#   This SDK will proceed with the AWS_PROFILE value.
#   However, a future version may change this behavior to prefer the ENV static credentials.

警告文にはこう書いてあるけど、という話でもあって、最後の行には「将来のバージョンで、環境変数の鍵を優先するように変えるかもしれない」とあります。今の挙動に合わせて手順を組むと、SDK を上げた日に逆転するかもしれないんですよね。どちらに転んでも困らないように、そもそも両方を置かないのが安全だと考えています。

警告はプロセスにつき 1 回だけでした。同じプロセスでクライアントを 2 つ作っても、2 回目は何も出ません。長く動くサーバーなら起動時のログに 1 行紛れるだけなので、気づくのはかなり難しいと思います。

もう 1 つ意外だったのが D の行です。AWS_PROFILE は置かずに、コードで profile: "dev" を渡しただけでも、Node.js は同じ警告を出しました。文面は「AWS_PROFILE と環境変数の鍵が両方ある」なのですが、このとき AWS_PROFILE は設定していません。コードで渡したプロファイル名も、同じ扱いになっているようです。使われた鍵は 4 つとも dev で揃ったので、実害はありません。

「どの鍵か」を道具の側で確かめる

偽の S3 が無くても、道具ごとに確かめる方法はあります。C の条件で、それぞれ何が見えたかを並べます。

道具確かめ方C の条件で見えたもの
AWS CLIaws configure listprofile の行は dev、access_key の行は末尾 ENT0(Type は env)
boto3session.get_credentials().methodenv
Node.js の SDK v3(await client.config.credentials()).$sourceCREDENTIALS_PROFILE(プロファイル)
Go の SDK v2cfg.Credentials.Retrieve(ctx) の SourceEnvConfigCredentials

CLI の aws configure list は少し注意が要ります。profile の行に dev と出るので、そこだけ見ると dev の鍵を使っているように読めてしまいます。実際に使われたのは access_key の行(末尾 ENT0、Type は env)のほうです。今思えば、私も以前は profile の行だけを見て安心していました。

Node.js の $source は、型定義では「出どころの記録(source attribution metadata)」と説明されている項目です。中身は短い記号(環境変数なら g、プロファイルなら n)で、SDK の機能の利用を記録するための ID のようです。そのため、ログに出して目で確かめる用途に留めています。

鍵がどこにも無いとき、諦めるまでに何秒かかるか

もう 1 つ測ったのが、鍵がどこにも無いときの振る舞いです。既定の探し方では、環境変数にもファイルにも鍵が無いと、最後に EC2 のインスタンスメタデータ(IMDS、169.254.169.254)へ問い合わせます。EC2 の上ならここでロールの一時的な鍵が取れますが、手元や CI では誰も答えません。

最初、私は「手元では関係ない話」だと思っていました。実際、Windows の手元で直接動かすと、どれもすぐに失敗します(Node.js は 0.02 秒)。ところが、同じことを Docker Desktop の Linux コンテナで試すと、Go の SDK は 9.3〜13.1 秒待ってから失敗しました(4 回測定)。コンテナの中では 169.254.169.254 への接続はすぐに拒否されるのですが、Go のエラーには「IMDS への問い合わせを 3 回試した」と「ListBuckets 全体を 3 回試した」が二重に出ています。拒否されるたびに、間を空けて送り直していたと考えられます。同じコンテナの Node.js は 0.13〜0.16 秒で失敗しました。

もう 1 つ、接続は受け付けるのに何も返さない相手も試しました。経路の途中でパケットが黙って捨てられる環境に近い状態です。手元にそういう「黙る」サーバーを立てて、AWS_EC2_METADATA_SERVICE_ENDPOINT でそこを IMDS に見立てています。

鍵が無いときの状況AWS CLIboto3Node.js の SDK v3Go の SDK v2
Windows の手元で直接(すぐ失敗)約 0.5 秒(CLI の起動時間と同じ)0.10〜0.14 秒0.02 秒(測っていない)
Linux コンテナ(接続はすぐ拒否される)(測っていない)(下の理由で除外)0.13〜0.16 秒9.3〜13.1 秒
接続は受け付けるが何も返さない約 2.5 秒(起動込み)・接続 2 回2.15 秒・接続 2 回2.03 秒・接続 2 回5.0 秒・接続 4 回
AWS_EC2_METADATA_DISABLED=true約 0.5 秒(起動時間と同じ)0.10 秒0.01 秒0.015 秒

何も返さない相手には、CLI・boto3・Node.js は 2 回つないでそれぞれ約 1 秒ずつ待ち(2 回の接続の間隔は 1.0 秒前後)、合わせて約 2 秒で諦めました。Go は 4 回つないで、約 5 秒で「context deadline exceeded」で打ち切っています。どの道具も、AWS_EC2_METADATA_DISABLED=true を置けば問い合わせ自体をしなくなり、すぐに「鍵が見つからない」と返します。手元や CI で IMDS を使うことはまず無いので、ここは止めておくのが筋だと考えています。

ここまでの数字を出すまでに、2 回つまずきました。

1 回目は CLI です。「黙る」サーバーを http://127.0.0.1:18124 のように末尾のスラッシュ無しで指定したら、CLI だけが Invalid endpoint EC2 Instance Metadata endpoint: http://127.0.0.1:18124latest/api/token で即座に失敗しました。ホストとパスがスラッシュ無しでくっついています。boto3 と Node.js は同じ値で問題なくつないでいたので、最初は CLI だけ IMDS を見に行かないのだと誤解しかけました。末尾にスラッシュを足すと、CLI も 2 回つないで約 2.5 秒になりました。

2 回目は boto3 のコンテナでの数字です。IMDS を止めた条件でも 5.4 秒かかっていて、止めていない条件(5.8〜6.3 秒)とほとんど差がありませんでした。IMDS を止めても変わらないので、原因は IMDS ではありません。Windows のフォルダをコンテナに渡し、そこから boto3 のパッケージ(サービス定義のファイルを含む)を読ませていたので、その読み込みに時間を取られていたと見ています。IMDS の待ちと切り分けられないため、この数字は表から外しました。測る道具の置き場所で結果が変わるのは、分かっていても毎回やってしまうところだったりします。

決めたこと

運用を考えると、決めごとは 3 つにしました。

1 つ目は、偽の S3 や MinIO を相手にするコードでは、クライアントに鍵を直接渡すことです。既定の探し方に任せると、表の C の行のように道具ごとに答えが変わります。直接渡した鍵は、環境変数の鍵と AWS_PROFILE が両方ある C の条件でも、そのまま使われました。

// ローカルの S3 互換ストレージ専用のクライアント。既定の探し方(AWS_PROFILE など)に任せない
import { S3Client } from "@aws-sdk/client-s3";

export const localS3 = new S3Client({
  endpoint: process.env.LOCAL_S3_ENDPOINT ?? "http://127.0.0.1:9000",
  region: "ap-northeast-1",
  forcePathStyle: true,
  credentials: {
    // 既定の探し方が読まない名前の環境変数から渡す(鍵の文字列はコードに書かない)
    accessKeyId: process.env.LOCAL_S3_ACCESS_KEY,
    secretAccessKey: process.env.LOCAL_S3_SECRET_KEY,
  },
});

2 つ目は、シェルに AWS_PROFILE と環境変数の鍵を同時に置かないことです。検証用のシェルでは最初に両方を消し、設定ファイルも作業用に差し替えます。3 つ目は、「どの鍵だったか」を作業ログに残すことです。CLI なら aws configure list の access_key の行を残します。

# 検証用シェルの冒頭に置く(偽の S3・MinIO を相手にするとき)
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
export AWS_CONFIG_FILE="$PWD/.aws-test/config"              # 本物の ~/.aws を読ませない
export AWS_SHARED_CREDENTIALS_FILE="$PWD/.aws-test/credentials"
export AWS_EC2_METADATA_DISABLED=true                       # 169.254.169.254 に問い合わせない
export AWS_ENDPOINT_URL=http://127.0.0.1:18123              # 接続先を偽の S3 に固定する
aws configure list | grep access_key                        # どの鍵かを作業ログに残す
# 改善の余地あり: これはシェルごとの約束なので、読み込み忘れると効かない。
# また AWS_ENDPOINT_URL はすべてのサービスの接続先を変えるため、同じシェルで本物の AWS を触る作業とは混ぜられない。

この冒頭の数行には、正直まだ改善の余地があります。読み込み忘れたら何も守ってくれませんし、鍵の出どころが想定と違っても止まらず、ログに残るだけです。

そもそも長期の鍵を端末に置かず、IAM Identity Center のような一時的な鍵に寄せるのが本筋だと考えています。ただ、ローカルのエミュレーターを相手にする検証では、AWS の鍵自体が要りません。そこで今回は、マネージドの仕組みを入れる前に、まず鍵を取り違えない形から手を付けました。

振り返って

「環境変数の鍵がいちばん強い」は、4 つのうち 3 つでは正しく、Node.js の SDK では正しくありませんでした。しかも、その Node.js も警告で「将来は変えるかもしれない」と言っています。優先順位を覚えるより、両方を置かない、使う鍵はコードで渡す、どれを使ったかを記録する、のほうが長く効くと考えています。

次は、今回の 5 つの条件と「鍵が無い」条件を偽の S3 ごと CI に置き、SDK を上げるたびに同じ表が出るかを自動で確かめるようにしたいと思っています。検証用シェルの冒頭も、鍵の出どころが想定と違えばその場で止まる形に作り直すつもりです。Go のコンテナで 13 秒待った件は、手元のテストで IMDS を止め忘れていないかを CI で見つけられるようにするところから始めます。