X API v2で投稿が403 Forbiddenになる。原因は tweet.read スコープの欠落だった
育児と趣味のことを書いているこのブログ。更新したらXにも自動で流したい。それだけの仕組みで、2日詰まった。
認証は最後まで通る。トークンも取れる。なのにAPIを叩くと全部403 Forbidden。しかもレスポンスに
理由が一行も書かれていない。自力で潰せる仮説を8つ試して、全部ハズレだった。
答えは X Developer Forum の中の人が教えてくれた。投稿しかしないつもりでも、tweet.read
スコープが必要だった。
同じところで止まっている人がそれなりにいそうなので、症状から解決までを順番に書いておく。
結論:tweet.write を使うなら tweet.read もセットで要求する
Table of Contents
Toggle先に答えだけ書く。OAuth 2.0の認可リクエストで、こう変えたら直った。
# 動かなかった
SCOPES = "tweet.write media.write users.read offline.access"
# 動いた
SCOPES = "tweet.read tweet.write media.write users.read offline.access"
tweet.read を足して認可をやり直すだけ。コードの他の部分は1文字も変えていない。
やっかいなのは、tweet.read が無いときのエラーが「スコープが足りません」ではなく、
ただの403 Forbiddenだという点だ。原因を指し示すものが何も返ってこない。
症状:認証は通るのに、v2のエンドポイントだけ全部403
まず、どういう状態だったのかを正確に書く。
- OAuth 2.0(PKCE)の認可画面 → 許可 → コード発行:成功
- 認可コードとアクセストークンの交換:200 OK
- リフレッシュトークンでの更新:200 OK
GET /2/users/me:403 ForbiddenPOST /2/tweets(画像なしの普通のテキスト投稿):403 ForbiddenGET /2/openapi.json:200 OK
403のレスポンスはこれだけ。
{
"title": "Forbidden",
"type": "about:blank",
"status": 403,
"detail": "Forbidden"
}
detail が "Forbidden"。何も言っていないに等しい。
ここで注目したのが GET /2/openapi.json が200を返すことだった。同じホスト、同じ経路で通信は
成立している。つまりネットワークやDNSの問題ではないし、トークンの付け方を間違えているわけでもない。
データを触るエンドポイントだけが選択的に弾かれているという状況だった。
潰した仮説8つ(全部ハズレ)
ここから2日かけた切り分け。結果的には全部外れたけれど、同じ症状の人が「それはもう試した」と
飛ばせるように残しておく。
- クレジットを買っていない → 購入済みでも変わらず
- Client Secretが壊れている → 再生成して認証やり直し。変わらず
- 開発者アカウントの電話番号が未登録 → 登録済み
- ブラウザのキャッシュ・古いセッション → シークレットウィンドウで認証。変わらず
- コールバックURLの末尾スラッシュ不一致 → 完全一致させた。変わらず
- スコープを要求しすぎ → 減らしてみた。変わらず(実際は逆だった)
- アクセストークンの期限切れ → 取得直後に叩いても403
- 2026年2月のX公式アナウンス(自動化対策)に引っかかった → 無関係だった
8番目は少し補足しておく。2026年2月24日にXが自動投稿まわりの制限を発表していて、これかと思った。
でも発表をちゃんと読むと、制限されたのはリプライと、@メンション・引用だけ。通常の投稿については
Regular post creation (non-replies) remains supported と明記されている。僕がやろうとしていたのは
リプライでもないただの投稿で、しかも読み取りAPIまで403だったので、別の問題だと判断した。
アナウンスを「たぶんこれだろう」で当てはめないで、原文を読んで範囲を確認する。 これは
やっておいてよかった。ここで納得していたら、諦めて終わっていた。
決定打:スコープは正しく付与されていた
2日目に、トークンそのものの中身を確認した。リフレッシュのレスポンスには、実際に付与された
スコープが入っている。
r = requests.post(
"https://api.x.com/2/oauth2/token",
auth=(CLIENT_ID, CLIENT_SECRET),
data={"grant_type": "refresh_token", "refresh_token": REFRESH_TOKEN},
)
print(r.json().get("scope"))
返ってきたのがこれ。
tweet.write users.read media.write offline.access
要求したスコープが、そのまま全部付いている。ここが分岐点だった。
users.read を持っているトークンが、users.read のエンドポイント(GET /2/users/me)で
403になっている。 要求漏れでもなければ、付与失敗でもない。だとすると、こちら側の書き方の
問題ではない。
この時点では「アプリの有効化がされていないのでは」と考えていた。結果的にこの推測は外れていた
のだけれど、「自分のコードの問題ではない」と切り分けられたこと自体が前進だった。おかげで
コードをいじる無限ループから抜けて、聞きに行く判断ができた。
X Developer Forum に投げたら、数時間で返信が来た
devcommunity.x.com に英語で投稿した。書いたのはこれだけ。
- 症状(どのエンドポイントが何を返すか)
- App IDとProject ID
- 試して外れた仮説の一覧
- こちらで追加で取れる情報があれば出す、という一文
Client SecretやトークンなどはX公式が相手でも書かない。 App IDとProject IDだけで、向こうは
アプリの状態を確認できる。
投稿の直後に「Account temporarily on hold」という通知が来て一瞬あせったけれど、これは新規
アカウントが長文+リンク付きで投稿したときに出るフォーラム側(Discourse)のスパム対策で、
X本体やAPIとは関係がない。スタッフが確認すれば解除される。
数時間後、X Staff の taycaldwell さんから1通目。
I’ll check enrollment on app [アプリID]. OAuth completing while every v2 data call returns a
generic 403 Forbidden is usually an enrollment/permissions issue on the app, not a bad token.
I’ll update here once we have a result.
(訳:アプリの有効化状況を確認します。OAuthが通るのにv2のデータ呼び出しが軒並み汎用の403に
なるのは、たいていトークンではなくアプリ側の有効化・権限の問題です。結果が出たら更新します)
こちらの切り分けと同じ見立てだった。追加情報として、さっきのスコープ検証の結果を返信した。
そして18時間後、2通目。
Please try adding tweet.read scope.
一行だった。試したら、その場で直った。
直したあとの結果
GET /2/users/me -> 200
POST /2/tweets -> 投稿成功
tweet.read を足して認可をやり直しただけ。2日粘った403が、一行で消えた。
同じ症状で止まっている人へ:確認する順番
X API v2で理由の書かれていない403に当たったら、この順で見ると早いと思う。
- 付与されたスコープを実際に印字する。 設定画面ではなく、トークンレスポンスの
scopeを見る。
要求したつもりと実際は食い違うことがある tweet.readが入っているか確認する。 投稿だけが目的でも入れる。ここが今回の答えだったGET /2/openapi.jsonを叩く。 200が返るなら、通信と認証ヘッダは正常。原因は権限側にあるGET /2/users/meとPOST /2/tweetsの両方を試す。 読み取りも書き込みも403なら、
個別の権限ではなくもっと手前の問題- 公式アナウンスは原文で範囲を確認する。 「自動投稿が制限された」という要約を鵜呑みにしない
- ここまでで分からなければ、devcommunity.x.com に英語で投げる。 症状・App ID・Project ID・
試したことを書く。今回は初回返信まで数時間だった
この6ステップのうち1と2をいちばん最初にやっていれば、2日は1時間で済んでいた。
ついでに踏んだ罠:リフレッシュトークンは使い捨て
これも書き残しておく。Xのリフレッシュトークンは1回使うと無効になり、新しいものが返ってくる。
つまり、動作確認のつもりで手動でcurlを叩くと、それだけで手元のリフレッシュトークンが死ぬ。
返ってきた新しいトークンを保存し忘れると、認可からやり直しになる。
なので、更新処理は必ず保存までを1つの関数にまとめておく。
tokens = resp.json()
access_token = tokens["access_token"]
# refresh_token は毎回新しくなる。必ず保存する
refresh_token = tokens.get("refresh_token", old_refresh_token)
save_to_env(access_token, refresh_token)
僕は最初これを知らずに手動で試して、一度認可からやり直した。
まとめ
- X API v2で投稿するには、
tweet.writeだけでなくtweet.readも必要 - 足りないときのエラーは理由なしの403 Forbidden。エラーメッセージからは特定できない
- 迷ったら、設定画面ではなくトークンレスポンスの
scopeを印字する GET /2/openapi.jsonが200なら、通信と認証は正常。権限側を疑う- リフレッシュトークンは使い捨て。新しいものを必ず保存する
- 自力の切り分けで手が尽きたら、早めに X Developer Forum に投げる。中の人の返信は速い
2日かかったけれど、「自分のコードは正しい」と確認できてから人に聞いたので、やりとりは
2往復で終わった。切り分けは、答えにたどり着くためだけでなく、質問を短くするためにも要る。
そこは今回いちばん手応えのあった部分だった。