冒頭まとめ

GCP の 401 Unauthorized は、「あなたが誰なのか分からない」という意味です。「あなたにその操作をする資格がない」ではありません。この2つは似て見えますが、GCP では明確に区別されています。

Google が公開しているエラー区分の定義ファイルを読むと、その区別が仕様として書かれています。401 に対応する区分の説明は「その操作に対する有効な認証情報を持っていない」という一文だけです。一方、403 に対応する区分の説明には、呼び出し元を特定できない場合にこれを使ってはならず、代わりに 401 の区分を使うこと、と明記されています。

つまり、認証情報が届いていない、あるいは読めない段階が 401 です。誰であるかは分かったが、その人にはその操作が許されていない段階が 403 です。この境界は、対処の方向を決めます。401 に対して権限の役割を追加しても、何も変わりません。

もう1つ、実務で誤解されやすい点があります。サービスアカウントの鍵は、既定では期限切れになりません。公式文書に、利用者が作成した鍵は既定では期限が無い、と明記されています。組織の方針で期限を設定した場合にのみ期限が発生します。したがって「鍵の期限切れ」を最初に疑うのは、多くの環境で見当違いです。

期限があるのは、短命の認証情報のほうです。こちらは既定で1時間、組織の設定を変えれば最大12時間まで延ばせます。長時間動く処理で 401 に当たるなら、疑うべきはこちらです。

エラーの概要

応答の形は他のエラーと共通で、status に区分名が入ります。

{
  "error": {
    "code": 401,
    "message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential.",
    "status": "UNAUTHENTICATED"
  }
}

statusUNAUTHENTICATED であることが、このエラーの性質を示しています。認証されていない、という区分です。

details 配列には、機械が読める識別子が入ります。設計の指針では、すべてのエラー応答が識別子を含むべきとされているため、reason の値で原因を分岐できます。

コマンド行の道具からは、認証情報が見つからない旨の文言が出ます。この場合、要求は送られてすらいないことがあります。手元で認証情報を探す段階で失敗しているためです。応答としての 401 なのか、手元での失敗なのかは、文言で区別できます。

まず最初に:誰として認証されているかを確認する

第一に、いま自分がどの身元で操作しているかを確認します。

gcloud auth list

第二に、実際に使われる認証情報が何かを確認します。ここが意図と違っていることが、このエラーの大半です。

gcloud auth application-default print-access-token > /dev/null && echo "既定の認証情報あり" || echo "既定の認証情報なし"

第三に、statusUNAUTHENTICATEDPERMISSION_DENIED かを見ます。後者であれば、認証は通っており、問題は権限の側です。調べる先が変わります(GCP の 403 の記事)。

よくある原因と解決手順

原因1:既定の認証情報が用意されていない

最も多い形です。手元の環境コンテナの中で、認証情報を探す仕組みが何も見つけられていません。

Before(用意せずに実行する):

python my_script.py
# → 認証情報が見つからない旨で失敗する

After(既定の認証情報を用意する):

gcloud auth application-default login
python my_script.py

ファイルを使う場合は、環境変数で場所を指定します。

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json

なお、gcloud auth logingcloud auth application-default login は別物です。前者はコマンド行の道具のための認証で、後者はプログラムのための認証です。前者だけを実行してプログラムから呼ぶと、このエラーになります。コマンドでは通るのにプログラムでは通らない、という現象はこれが原因です。

原因2:短命の認証情報が期限切れになっている

長時間動く処理で起きる形です。公式文書のとおり、短命の認証情報は既定で1時間で期限切れになります。処理の開始時に取得した情報を持ち回っていると、途中で無効になります。

Before(一度取得した値を持ち回る):

token = get_access_token()          # 開始時に1回だけ取得
for item in huge_list:              # 1時間を超える処理
    call_api(item, token)           # 途中から 401 になる

After(公式の開発キットに任せる):

from google.cloud import storage
client = storage.Client()           # 更新は内部で行われる
for item in huge_list:
    client.bucket(item).exists()

公式の開発キットは、期限が近づくと自動で取り直します。自分で値を取り出して持ち回る作りが、この問題を招きます。

どうしても期限を延ばす必要がある場合、組織の方針で最大12時間まで設定できます。ただし、これは短命であることの利点を減らす変更なので、処理を分割できないか先に検討してください。

原因3:鍵が無効化されている

鍵の期限切れではなく、鍵そのものが削除された、あるいはサービスアカウントが無効化された場合です。前述のとおり、鍵は既定で期限切れになりません。したがって、突然使えなくなったなら、期限ではなく削除や無効化を疑います。

# 鍵の一覧と有効期限を確認する
gcloud iam service-accounts keys list \
  --iam-account=<サービスアカウント>@<プロジェクト>.iam.gserviceaccount.com

# サービスアカウント自体が有効かを確認する
gcloud iam service-accounts describe \
  <サービスアカウント>@<プロジェクト>.iam.gserviceaccount.com

一覧に鍵が出てこなければ削除されています。disabled が真であれば、サービスアカウントが無効化されています。

なお、組織の方針で鍵に期限を設定している環境では、期限切れが実際に起こります。その場合、一覧の有効期限の欄に日付が入ります。既定のままなら、遠い未来の日付になります。

原因4:役割を追加しても直らない

冒頭で述べたとおり、401 は権限の問題ではありません。区分の定義に、呼び出し元を特定できない場合に 403 の区分を使ってはならないと明記されています。裏を返せば、401 が返っている時点で、呼び出し元が特定できていないということです。

したがって、次の対処は効果がありません。

Before(役割を追加して解決しようとする):

gcloud projects add-iam-policy-binding <プロジェクト> \
  --member=serviceAccount:<サービスアカウント> \
  --role=roles/owner
# → 401 は変わらない

After(まず身元が届いているかを確認する):

gcloud auth list
gcloud config list account

役割の追加が効くのは、statusPERMISSION_DENIED の場合、つまり 403 のときです。旧来の解説では、この2つを混ぜて説明しているものが少なくありません。区分名で判断してください。

補足:似ているが別のもの

権限の不足は 403 です。区分の定義に、資源を使い切ったことによる拒否にこの区分を使ってはならず、その場合は量の超過の区分を使うこと、そして呼び出し元を特定できない場合にも使ってはならないことが書かれています。さらに、この区分は要求が妥当であることや対象が存在することを意味しない、とも述べられています(GCP の 403 の記事)。

呼び出し先の APIプロジェクトで有効化されていない場合も、返るのは 403 です。statusPERMISSION_DENIED、識別子は SERVICE_DISABLED(古い形式では accessNotConfigured)で、文言にその APIプロジェクトで使われていない、あるいは無効化されている旨と、有効化のための URL が入ります。認証情報が正しくても起こりますが、401 の側の問題ではありません。

要求の頻度や量が上限を超えた場合は 429 です(GCP の 429 の記事)。認証の問題と混同しやすいのは、どちらも「拒否された」と見えるためですが、区分は別です。

送った内容そのものに問題がある場合は 400 で、区分が3つに分かれます(GCP の 400 の記事)。対象が見つからない場合は 404 です(GCP の 404 の記事)。

なお、権限が無いことを隠すために、存在する対象を存在しないものとして返す設計を採るサービスもあります。GCP でも、対象の存在を秘匿する必要がある場合に 404 が返ることがあります。403 と 404 が混ざる場面では、この可能性も考えてください。

切り分けの順序

  1. status の値を見る。UNAUTHENTICATED なら身元の問題、PERMISSION_DENIED なら権限の問題。
  2. いまどの身元で操作しているかを確認する。意図と違っていないか。
  3. プログラムから呼んでいるなら、既定の認証情報が用意されているかを確認する。コマンド用の認証とは別物。
  4. 長時間動く処理なら、短命の認証情報の期限切れを疑う。既定は1時間。
  5. 突然使えなくなったなら、鍵の期限ではなく削除や無効化を疑う。鍵は既定で期限切れにならない。
  6. 役割の追加は 401 には効かない。効くのは 403 のとき。
  7. 身元に問題が無いのに拒否されるなら、区分名を読み直す。API の未有効化は 401 ではなく 403(SERVICE_DISABLED)で現れる。

確認コマンド集

# 1. いまどの身元で操作しているかを確認する
gcloud auth list
gcloud config list account

# 2. プログラム用の既定の認証情報があるかを確認する
gcloud auth application-default print-access-token > /dev/null \
  && echo "あり" || echo "なし"

# 3. 応答の status と reason を取り出す
curl -sS -H "Authorization: Bearer $(gcloud auth print-access-token)" \
  "https://<サービス>.googleapis.com/v1/<資源>" | python3 -c "
import json,sys
d=json.load(sys.stdin)['error']
print(d['code'], d['status'])
for x in d.get('details', []):
    if x['@type'].endswith('ErrorInfo'):
        print('  reason:', x.get('reason'), '/ domain:', x.get('domain'))
"

# 4. 鍵の一覧と有効期限を確認する
gcloud iam service-accounts keys list \
  --iam-account=<サービスアカウント>@<プロジェクト>.iam.gserviceaccount.com

# 5. サービスアカウントが無効化されていないかを確認する
gcloud iam service-accounts describe \
  <サービスアカウント>@<プロジェクト>.iam.gserviceaccount.com \
  --format="yaml(disabled, email)"

# 6. 送受信の内容をそのまま見る
gcloud <サービス> <操作> --log-http 2>&1 | grep -i "authorization\|www-authenticate" | head

Editor’s Note

401 と 403 の混同は、GCP に限らず広く見られます。しかし GCP の場合、混同してはならないことが仕様として書かれている点が特徴です。

区分の定義には、403 に対応する区分について3つの禁止事項が並んでいます。資源を使い切ったことによる拒否には使わないこと、呼び出し元を特定できない場合には使わないこと、そしてこの区分が要求の妥当性や対象の存在を意味しないこと。とりわけ2つ目が、本記事の主題そのものです。

この境界が実際の調査でどう働くかを示す記録があります。GKE 上の external-dns から DNSAPI を呼ぶ構成の不具合報告(kubernetes-sigs/external-dns Issue #1020)です。報告の本題は 403(insufficient authentication scopes)、つまり認証は届いているのにスコープが足りないという 403 側の問題でした。報告者は切り分けのために条件を変えて試しており、サービスアカウントの鍵を無効にするとトークン取得の段階で invalid_grant(Invalid JWT Signature)が返り、サービスアカウント自体を消すと API の段階で 401(Reason: authError)が返っています。どの段階で身元の確認に失敗したかが、返るエラーの種類にそのまま現れる、という本記事の骨格どおりの実例です。

旧来の解説には、401 の原因として「必要な役割が付与されていない」を挙げるものがあります。これは仕様と食い違っています。役割の不足は、身元が特定できたうえでの拒否なので、403 の側に属します。役割を追加して 401 が直った経験があるとすれば、それは同時に別の変更を行っていたか、あるいは元のエラーが 403 だったかのどちらかです。

もう1つ、鍵の期限についても同じことが言えます。公式文書には、利用者が作成した鍵は既定で期限切れにならないと明記され、本番環境では期限を設けず管理で対処することが推奨されています。理由も添えられていて、期限切れの鍵は意図しない停止を招くから、とされています。「鍵の期限が切れたのでは」という直感は、この設計を知っていれば最初に外せます。

401 に当たったら、まず status を読む。そして、身元が届いているかだけを考える。権限の話は、その次の段階です。


免責事項:本記事の内容は、執筆時点の公開情報をもとに作成したものです。ソフトウェアの仕様は予告なく変更されることがあります。最新の情報は各ツールの公式サポートページをご確認ください。本記事の情報を利用した結果生じたいかなる損害についても、著者および運営者は責任を負いかねます。