運営者がブログ運用のために作っているツール(Python製)は、AdSenseの数字をAPIで取得しています。その認証をやり直す機能で、毎回 invalid_grant になって先へ進めなくなったことがあります。このサイトの記事の作り方とツールの関係は運営者情報にまとめています。
起きていたこと
2026年8月23日の記録です。ツールの画面から再認証を始めると、Googleの許可画面までは進みます。許可してツールへ戻ってきたところで「認証情報の取得に失敗しました」と出て止まりました。
Googleが返していたのは invalid_grant でした。リダイレクトURI、クライアントの設定、state の照合はどれも正しいことを確かめたうえでの失敗です。
invalid_grant というと、保存しているリフレッシュトークンが切れた、と読みたくなります。ですが今回は、認可コードをトークンへ交換する最初の段階で失敗していました。PKCE を定めた RFC 7636 の4.6には、検証用の値が一致しなければ invalid_grant を返すと書かれています。
If the values are equal, the token endpoint MUST continue processing
as normal (as defined by OAuth 2.0 [RFC6749]). If the values are not
equal, an error response indicating "invalid_grant" as described in
Section 5.2 of [RFC6749] MUST be returned.
原因:開始と戻りで Flow が別だった
ツールは google-auth-oauthlib の Flow を使っています。再認証は「許可画面のURLを作る」リクエストと、「戻ってきた認可コードを交換する」リクエストの2回に分かれていて、それぞれで Flow を作っていました。
インストールされているバージョン(1.4.0)のソースを読むと、authorization_url() の中で code_verifier が無ければ128文字の値を作り、そのハッシュを code_challenge として認可URLへ付けています。
if self.code_verifier is None and self.autogenerate_code_verifier:
chars = ascii_letters + digits + "-._~"
rnd = SystemRandom()
random_verifier = [rnd.choice(chars) for _ in range(0, 128)]
self.code_verifier = "".join(random_verifier)
autogenerate_code_verifier の既定は True なので、何も指定しなくてもPKCEが付きます。交換する fetch_token() のほうは、自分の code_verifier をそのまま送ります。
kwargs.setdefault("code_verifier", self.code_verifier)
つまり、URLを作った Flow にだけ code_verifier があり、戻りで新しく作った Flow には無い状態でした。Googleは code_challenge を受け取っているのに、交換時には検証用の値が届かないので拒否します。ライブラリのドキュメントの authorization_url() の説明には、別の Flow でトークンを取るなら state を指定する必要がある、とは書かれています。code_verifier については触れられておらず、ソースを読んで分かりました。
ネットワークに出ずに再現する
2026年10月19日に、Googleへは接続せずに確かめました。クライアントIDと秘密鍵は dummy.apps.googleusercontent.com のようなダミーです。トークンの交換は requests_oauthlib の OAuth2Session.fetch_token を差し替え、送ろうとした code_verifier を記録するだけにしています。
from unittest import mock
from urllib.parse import urlparse, parse_qs
from google_auth_oauthlib.flow import Flow
import requests_oauthlib
CONFIG = {"installed": {
"client_id": "dummy.apps.googleusercontent.com",
"client_secret": "dummy-secret",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}}
SCOPES = ["https://www.googleapis.com/auth/adsense.readonly"]
REDIRECT = "http://localhost:8080/callback"
def make(**kw):
return Flow.from_client_config(CONFIG, scopes=SCOPES, redirect_uri=REDIRECT, **kw)
sent = {}
def fake_fetch_token(self, token_url, **kwargs):
sent["code_verifier"] = kwargs.get("code_verifier")
return {"access_token": "x", "token_type": "Bearer"}
# 1) 開始時の Flow A
a = make()
url, state = a.authorization_url(prompt="consent")
# 2) 戻りで作り直した Flow B(直す前の形)
with mock.patch.object(requests_oauthlib.OAuth2Session, "fetch_token", fake_fetch_token):
b = make(state=state)
b.fetch_token(code="dummy-code")
# 3) 直した後:A の値を保存しておき、戻りの Flow に渡す
saved = a.code_verifier
with mock.patch.object(requests_oauthlib.OAuth2Session, "fetch_token", fake_fetch_token):
c = make(state=state, autogenerate_code_verifier=False)
c.code_verifier = saved
c.fetch_token(code="dummy-code")
実際のスクリプトでは、各段階で値を表示しています。出力は次のとおりです。
google-auth-oauthlib 1.4.0
A 生成直後 code_verifier: None
A 認可URL後 code_verifier: 長さ 128 文字
認可URLの code_challenge_method: ['S256']
認可URLに code_challenge がある: True
認可URLに code_verifier がある : False
B 生成直後 code_verifier: None
B が送ろうとした code_verifier: None
修正後 送ろうとした code_verifier が A と同じ: True
B' で作り直した verifier が A と同じ: False
A は authorization_url() を呼んだ時点で128文字の code_verifier を持ち、認可URLには code_challenge と S256 が付きます。code_verifier そのものはURLに載りません。B は作った直後から None のままで、交換でも None を渡そうとしています。これが invalid_grant の正体でした。
最後の行は、戻り側で authorization_url() を呼び直しても、別の値ができるだけで役に立たないことを示しています。
差し替える場所にも注意が要ります。Flow.fetch_token を丸ごと差し替えると、code_verifier を載せる処理そのものを飛ばしてしまい、直す前のコードでもテストが通ります。ツールの回帰テストも OAuth2Session.fetch_token の側を差し替えています。
直し方
許可画面のURLを作ったときに code_verifier を state と一緒に取っておき、戻りで作る Flow に渡します。
# 開始時
auth_url, state = flow.authorization_url(prompt="consent", state=state)
pending[state] = {"code_verifier": flow.code_verifier, ...}
# 戻り
p = pending.pop(state)
flow = Flow.from_client_secrets_file(path, scopes=scopes, redirect_uri=redirect_uri,
state=state, autogenerate_code_verifier=False)
flow.code_verifier = p["code_verifier"]
flow.fetch_token(code=code)
ツールでは、この値をメモリだけに持ち、ディスクにもログにも書きません。ツールを再起動すれば消えるので、そのときは認証を最初からやり直してもらいます。state と同じく、1回使ったら捨てます。
あわせて直したこと
- 交換の失敗をすべて「取得に失敗しました」にまとめず、Googleが返した
errorから「コードが使用済み」「期限切れ」「検証情報が失われた」などに分けて表示するようにしました - Googleが要求より広いスコープを返すことがあり、そのままだと
oauthlibが例外にします。交換の間だけOAUTHLIB_RELAX_TOKEN_SCOPEを立て、交換の後で必要なスコープが本当に付いたかを確かめて、足りなければ保存しないようにしました - 認可コードがアクセスログに平文で残っていたので、伏せ字にしました
なお、リフレッシュトークンが数日で切れる問題は、これとは別に観測しています。原因はまだ特定できていないので、ここでは扱いません。
自分のコードで確かめること
- 認可URLを作る処理とコードを交換する処理で、同じ
Flowを使っているか。別ならcode_verifierを渡しているか invalid_grantが出たとき、それが交換時(認可コード)なのか更新時(リフレッシュトークン)なのかを分けて見ているか- テストで
Flow.fetch_tokenを丸ごと差し替えていないか code_verifierや認可コードがログに出ていないか
検証した環境
この記事の調査・再現・執筆は、運営者の指示のもとでAIエージェント(AnthropicのClaude)が行い、公開するかどうかは運営者が決めています。載せた出力は、そのAIエージェントがこのMacで実際にコマンドを実行して得たものです。
- macOS 26.6.2
- Python 3.12.13
- google-auth-oauthlib 1.4.0 / requests-oauthlib 2.0.0 / oauthlib 3.3.1
- 再現は2026年10月19日。Googleへの通信はしていません(ダミーの設定と差し替えのみ)